Arquitetura do Shadow DOM
O Shadow DOM e um dos aspectos mais desafiadores da automacao web moderna. Elementos dentro de shadow trees sao invisiveis para consultas DOM regulares, o que quebra abordagens tradicionais de automacao. Este documento explica como o Shadow DOM funciona no nivel do navegador, por que ferramentas convencionais falham com shadow roots fechados, e como o Pydoll contorna essas restricoes atraves de acesso direto via CDP.
Guia de Uso Pratico
Para exemplos de uso e padroes de inicio rapido, consulte o Guia de Pesquisa de Elementos — secao Shadow DOM.
O que e Shadow DOM?
Shadow DOM e um padrao web que permite encapsulamento DOM. Ele permite que um componente tenha sua propria arvore DOM isolada (a "shadow tree") anexada a um elemento DOM regular (o "shadow host"). Elementos dentro de uma shadow tree ficam ocultos das consultas do documento principal.
graph TB
subgraph "DOM Principal (Light DOM)"
Document["document"]
Host["div#my-component\n(shadow host)"]
Other["p.normal-content"]
end
subgraph "Shadow Tree (Encapsulada)"
SR["#shadow-root (open)"]
Style["style"]
Button["button.internal"]
Input["input.private"]
end
Document --> Host
Document --> Other
Host -.->|"attachShadow()"| SR
SR --> Style
SR --> Button
SR --> Input
Modos do Shadow Root
Quando um componente cria um shadow root via attachShadow(), ele especifica um modo:
| Modo | Acesso JavaScript | Acesso CDP | Uso Comum |
|---|---|---|---|
open |
element.shadowRoot retorna o root |
Acesso total via backendNodeId |
Web components customizados (Lit, Stencil) |
closed |
element.shadowRoot retorna null |
Acesso total via backendNodeId |
Componentes sensiveis, formularios de pagamento |
user-agent |
Nao acessivel via JS | Acesso limitado | UI interna do navegador (placeholders, controles de video) |
Essa distincao e critica: o acesso no nivel JavaScript e restrito pelo modo, mas o acesso no nivel CDP nao e.
Por que a Automacao Tradicional Falha
Ferramentas de automacao tradicionais dependem da execucao de JavaScript no contexto da pagina:
// Abordagem WebDriver / Selenium
document.querySelector('#my-component') // ✓ Encontra o host
document.querySelector('#my-component button') // ✗ Nao cruza a fronteira do shadow
element.shadowRoot // ✗ Retorna null para roots fechados
A fronteira do shadow e imposta pelo motor JavaScript do navegador. Qualquer ferramenta de automacao que executa JavaScript para encontrar elementos vai encontrar essa barreira. Isso inclui Selenium, page.evaluate() do Playwright, e qualquer ferramenta usando Runtime.evaluate() com document.querySelector() no nivel do documento.
Como o Pydoll Contorna as Fronteiras do Shadow
A abordagem do Pydoll funciona em uma camada abaixo do JavaScript: o Chrome DevTools Protocol. O CDP tem acesso direto a representacao interna do DOM do navegador, que ignora restricoes de modo do shadow completamente.
A Vantagem do CDP
sequenceDiagram
participant User as Codigo do Usuario
participant SR as ShadowRoot
participant CH as ConnectionHandler
participant CDP as Chrome CDP
participant DOM as DOM do Navegador
User->>SR: shadow_root.query('.btn')
SR->>SR: _get_find_element_command(object_id)
SR->>CH: execute_command(Runtime.callFunctionOn)
CH->>CDP: WebSocket send
CDP->>DOM: Executa querySelector no objeto shadow root
DOM-->>CDP: Resultado do elemento
CDP-->>CH: Resposta com objectId
CH-->>SR: Dados do elemento
SR-->>User: Instancia WebElement
O insight chave esta em como o objeto shadow root e obtido e como as consultas sao executadas contra ele:
- Descoberta:
DOM.describeNodecompierce=trueretorna nos de shadow root com seubackendNodeId, independente do modo - Resolucao:
DOM.resolveNodeconverte umbackendNodeIdem umobjectIdJavaScript que referencia o shadow root diretamente - Consulta:
Runtime.callFunctionOnexecutathis.querySelector()noobjectIddo shadow root; isso funciona porque a chamada e feita no proprio objeto shadow root, nao a partir do contexto do documento
Passo a Passo: Acesso ao Shadow Root
flowchart TD
A["WebElement\n(shadow host)"]
B["shadowRoots[] com\nbackendNodeId"]
C["objectId JavaScript\npara o shadow root"]
D["Instancia ShadowRoot"]
E["WebElement\n(dentro do shadow)"]
A -->|"DOM.describeNode\ndepth=1, pierce=true"| B
B -->|"DOM.resolveNode\nbackendNodeId"| C
C -->|"Criar ShadowRoot\ncom objectId"| D
D -->|"find() / query()\nvia callFunctionOn"| E
Passo 1: Descrever o No Host
# Pydoll envia este comando CDP:
{
"method": "DOM.describeNode",
"params": {
"objectId": "<host-element-object-id>",
"depth": 1,
"pierce": true # ← Esta e a flag chave
}
}
O parametro pierce diz ao CDP para atravessar fronteiras do shadow ao descrever o no. A resposta inclui informacoes do shadow root independente do modo do shadow root:
{
"result": {
"node": {
"nodeName": "DIV",
"shadowRoots": [
{
"nodeId": 0,
"backendNodeId": 5,
"shadowRootType": "closed",
"childNodeCount": 4
}
]
}
}
}
nodeId vs backendNodeId
Quando o dominio DOM nao esta explicitamente habilitado (que e o padrao do Pydoll para minimizar overhead), nodeId e sempre 0. O backendNodeId e o identificador estavel e sempre disponivel. O Pydoll usa backendNodeId exclusivamente para resolucao de shadow root, e por isso funciona sem necessitar de DOM.enable().
Passo 2: Resolver para Objeto JavaScript
# Converter backendNodeId em um objectId utilizavel:
{
"method": "DOM.resolveNode",
"params": {
"backendNodeId": 5
}
}
A resposta fornece um objectId, um handle para o shadow root no espaco de objetos do JavaScript:
Passo 3: Consultar Dentro do Shadow Root
Com o objectId do shadow root, o Pydoll aproveita o mecanismo de busca relativa existente do FindElementsMixin:
# Quando ShadowRoot.query('.btn') e chamado:
{
"method": "Runtime.callFunctionOn",
"params": {
"functionDeclaration": "function() { return this.querySelector(\".btn\"); }",
"objectId": "-2296764575741119861.1.3"
}
}
A funcao executa com this vinculado ao objeto shadow root. Como shadow roots implementam as interfaces querySelector() e querySelectorAll() nativamente, seletores CSS funcionam naturalmente dentro da fronteira do shadow.
Arquitetura do ShadowRoot
Decisao de Design: Reutilizar FindElementsMixin
A decisao arquitetural mais critica foi fazer ShadowRoot herdar de FindElementsMixin:
class ShadowRoot(FindElementsMixin):
def __init__(self, object_id, connection_handler, mode, host_element):
self._object_id = object_id # Referencia CDP do shadow root
self._connection_handler = connection_handler # Para comunicacao CDP
self._mode = mode # Enum ShadowRootType
self._host_element = host_element # Referencia de volta ao host
Por que isso funciona: FindElementsMixin._find_element() verifica hasattr(self, '_object_id'). Quando presente, usa RELATIVE_QUERY_SELECTOR, que chama this.querySelector() no objeto referenciado. Como shadow roots suportam querySelector() nativamente, query() com seletores CSS funciona automaticamente. A flag _css_only = True no ShadowRoot bloqueia find() e query() com XPath, lancando NotImplementedError.
# Esta unica linha no FindElementsMixin habilita buscas em shadow root:
elif hasattr(self, '_object_id'):
command = self._get_find_element_command(by, value, self._object_id)
Isso significa que ShadowRoot herda query() e find_or_wait_element() do mixin. Porem, a flag _css_only = True restringe o uso a apenas query() com seletores CSS; find() e XPath lancam NotImplementedError.
Consistencia Arquitetural
Este e o mesmo mecanismo que faz WebElement.find() buscar dentro dos filhos de um elemento: o atributo _object_id sinaliza "busque relativo a mim" em vez de "busque no documento inteiro." ShadowRoot, WebElement e Tab compartilham comportamento identico de busca de elementos atraves do FindElementsMixin.
Relacionamento entre Classes
| Classe | Tem _object_id |
Tem _connection_handler |
Escopo de Busca |
|---|---|---|---|
Tab |
Nao | Sim | Documento inteiro |
WebElement |
Sim | Sim | Dentro da subarvore do elemento |
ShadowRoot |
Sim | Sim | Dentro da shadow tree |
Todos os tres herdam de FindElementsMixin. A presenca ou ausencia de _object_id determina se as buscas sao globais no documento ou com escopo para um no especifico.
Resolvendo Shadow Roots: Estrategia backendNodeId
O Pydoll deliberadamente usa backendNodeId em vez de nodeId para resolucao de shadow root:
| Propriedade | nodeId |
backendNodeId |
|---|---|---|
Requer DOM.enable() |
Sim | Nao |
| Estavel entre chamadas describe | Nao (0 quando DOM nao habilitado) | Sim |
| Funciona para resolucao de shadow root | Apenas com DOM habilitado | Sempre |
| Overhead de performance | Maior (rastreamento do dominio DOM) | Nenhum |
Ao confiar no backendNodeId, o Pydoll evita o overhead de habilitar o dominio DOM enquanto mantem acesso confiavel ao shadow root. Esta e uma escolha pragmatica: a maioria dos cenarios de automacao nao precisa do stream de eventos do dominio DOM, e habilita-lo adiciona overhead de memoria e processamento para rastrear cada mutacao do DOM.
Shadow Roots Fechados: Por que o Acesso CDP Funciona
Esta e a pergunta mais frequente: se element.shadowRoot retorna null para shadow roots fechados em JavaScript, como o CDP pode acessa-los?
A resposta esta em entender a arquitetura do navegador:
graph TB
subgraph "Runtime JavaScript"
JS["Codigo JavaScript"]
API["Web APIs\n(propriedade shadowRoot)"]
end
subgraph "Internos do Navegador"
CDP_Layer["Camada CDP"]
DOM_Internal["Arvore DOM Interna"]
end
JS -->|"element.shadowRoot"| API
API -->|"mode == 'closed'\n→ retorna null"| JS
CDP_Layer -->|"DOM.describeNode\npierce=true"| DOM_Internal
DOM_Internal -->|"Sempre retorna\nshadow tree completa"| CDP_Layer
Acesso JavaScript passa pela camada de Web API, que impoe a restricao de modo do shadow. Quando mode='closed', a API retorna null; esta e uma fronteira de controle de acesso intencional para codigo de paginas web.
Acesso CDP opera abaixo da camada de Web API. Ele se comunica diretamente com a representacao interna do DOM do navegador. A restricao do modo closed e uma politica no nivel JavaScript, nao uma restricao no nivel DOM. A shadow tree ainda existe no DOM; ela apenas esta oculta da visao do JavaScript.
Implicacoes de Seguranca
Isso e por design no DevTools Protocol. O CDP e destinado a ferramentas de depuracao e automacao que precisam de acesso total ao DOM. O modo closed protege conteudos do shadow de outros scripts na mesma pagina (ex: scripts de terceiros), nao da interface de depuracao do navegador. Esta e a mesma razao pela qual o DevTools do navegador consegue inspecionar shadow roots fechados no painel Elements.
Verificacao Pratica
Voce pode verificar esse comportamento:
import asyncio
from pydoll.browser.chromium import Chrome
from pydoll.protocol.dom.types import ShadowRootType
async def verify_closed_access():
async with Chrome() as browser:
tab = await browser.start()
await tab.go_to('about:blank')
# Criar um shadow root fechado via JavaScript
await tab.execute_script("""
const host = document.createElement('div');
host.id = 'test-host';
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'closed' });
shadow.innerHTML = '<p class="secret">Conteudo oculto</p>';
""")
# JavaScript nao consegue acessar:
result = await tab.execute_script(
"return document.getElementById('test-host').shadowRoot",
return_by_value=True,
)
js_value = result['result']['result'].get('value')
print(f"JS shadowRoot: {js_value}") # None
# Mas o Pydoll consegue:
host = await tab.find(id='test-host')
shadow = await host.get_shadow_root()
print(f"Modo do shadow: {shadow.mode}") # ShadowRootType.CLOSED
secret = await shadow.query('.secret')
text = await secret.text
print(f"Conteudo: {text}") # "Conteudo oculto"
asyncio.run(verify_closed_access())
Shadow Roots Aninhados
Web components frequentemente compoem outros web components, criando shadow trees em multiplos niveis:
graph TB
subgraph "Light DOM"
Host1["outer-component\n(shadow host)"]
end
subgraph "Shadow Tree Externa"
SR1["#shadow-root (open)"]
Host2["inner-component\n(shadow host)"]
P1["p.outer-text"]
end
subgraph "Shadow Tree Interna"
SR2["#shadow-root (closed)"]
Button["button.deep-btn"]
P2["p.inner-text"]
end
Host1 -.-> SR1
SR1 --> P1
SR1 --> Host2
Host2 -.-> SR2
SR2 --> P2
SR2 --> Button
O Pydoll lida com isso naturalmente encadeando chamadas get_shadow_root(). Cada ShadowRoot produz instancias WebElement que podem ter seus proprios shadow roots:
outer_host = await tab.find(tag_name='outer-component')
outer_shadow = await outer_host.get_shadow_root() # open
inner_host = await outer_shadow.query('inner-component')
inner_shadow = await inner_host.get_shadow_root() # closed, ainda funciona
deep_button = await inner_shadow.query('.deep-btn')
await deep_button.click()
Cada nivel segue o mesmo fluxo de resolucao CDP: describeNode depois resolveNode depois ShadowRoot com _object_id depois querySelector via callFunctionOn.
Shadow Roots Dentro de IFrames
Um cenario comum no mundo real envolve shadow roots dentro de iframes cross-origin — por exemplo, captchas Cloudflare Turnstile. Isso combina dois mecanismos de isolamento: a fronteira do iframe e a fronteira do shadow.
graph TB
subgraph "Pagina Principal"
Host["div.widget\n(shadow host)"]
end
subgraph "Shadow Tree"
SR1["#shadow-root"]
IFrame["iframe\n(cross-origin)"]
end
subgraph "IFrame (OOPIF)"
Body["body"]
end
subgraph "Shadow Tree do IFrame"
SR2["#shadow-root"]
Button["label.checkbox"]
end
Host -.-> SR1
SR1 --> IFrame
IFrame -.->|"processo separado"| Body
Body -.-> SR2
SR2 --> Button
O Pydoll lida com isso de forma transparente atraves da propagacao de contexto do iframe. Quando um ShadowRoot e criado, ele herda o contexto de roteamento do iframe do seu elemento host:
# A cadeia completa: pagina principal → shadow root → iframe → shadow root → elemento
shadow_host = await tab.find(id='widget-container')
first_shadow = await shadow_host.get_shadow_root()
iframe = await first_shadow.query('iframe')
body = await iframe.find(tag_name='body')
second_shadow = await body.get_shadow_root()
# click() funciona corretamente — eventos de mouse roteados pela sessao OOPIF
button = await second_shadow.query('label.checkbox')
await button.click()
Como a Propagacao de Contexto Funciona
IFrames cross-origin rodam em um processo separado do navegador (Out-of-Process IFrame, ou OOPIF). Comandos CDP para esses iframes devem ser roteados atraves de um sessionId dedicado. O Pydoll propaga esse contexto de roteamento automaticamente por toda a cadeia:
- IFrame resolve seu contexto:
iframe.find()estabelece umIFrameContextcomsession_idesession_handlerpara o OOPIF - Elementos filhos herdam o contexto: Elementos encontrados dentro do iframe recebem o
IFrameContext - Shadow roots herdam do host:
ShadowRootcopia o_iframe_contextdo seu elemento host - Elementos no shadow herdam do shadow root: Elementos encontrados via
shadow.query()recebem o contexto propagado - Comandos roteiam corretamente:
_execute_command()detecta o contexto herdado e roteia comandos CDP (incluindoInput.dispatchMouseEventparaclick()) pela sessao OOPIF
Isso significa que coordenadas de DOM.getBoxModel (que sao relativas ao viewport do iframe) sao corretamente pareadas com eventos de mouse despachados para a mesma sessao OOPIF.
Buscando Shadow Roots: find_shadow_roots()
Tab.find_shadow_roots() percorre toda a arvore DOM para coletar todos os shadow roots encontrados na pagina.
Como Funciona
Tab.find_shadow_roots()
├─ DOM.getDocument(depth=-1, pierce=true)
│ └─ Retorna arvore DOM completa com arrays shadowRoots
├─ Percurso recursivo da arvore: _collect_shadow_roots_from_tree()
│ ├─ Coleta entradas shadowRoots com backendNodeId do host
│ ├─ Percorre filhos recursivamente
│ └─ Percorre contentDocument (iframes same-origin)
├─ Para cada entrada de shadow root:
│ ├─ DOM.resolveNode(backendNodeId) → objectId
│ └─ Resolver elemento host (melhor esforco)
└─ Retorna list[ShadowRoot] com referencias de host
Timeout: Esperando Shadow Roots
Shadow hosts sao frequentemente injetados de forma assincrona. Tab.find_shadow_roots() aceita um parametro timeout que faz polling a cada 0.5s ate que pelo menos um shadow root seja encontrado ou o timeout expire (lanca WaitElementTimeout). Da mesma forma, WebElement.get_shadow_root() tambem suporta timeout para esperar pelo shadow root de um elemento especifico:
# Esperar ate 10 segundos pelos shadow roots
shadow_roots = await tab.find_shadow_roots(timeout=10)
# Esperar pelo shadow root de um elemento especifico
shadow = await element.get_shadow_root(timeout=5)
Detalhes Importantes
pierce=TrueemDOM.getDocumentfaz o navegador incluir arraysshadowRootsnas descricoes de nos, permitindo a descoberta de todos os shadow roots sem navegar individualmente ate cada host.- Conteudo de iframes same-origin e incluido na arvore via nos
contentDocument. A travessia os manipula. - Cada
ShadowRootretornado tem uma referencia ao seuhost_element(resolvido por melhor esforco viaDOM.resolveNode).
Travessia Profunda: IFrames Cross-Origin (OOPIFs)
Por padrao, iframes cross-origin (OOPIFs) nao sao incluidos na arvore DOM — seu conteudo vive em um processo separado do navegador. Passe deep=True para tambem descobrir shadow roots dentro de OOPIFs:
Quando deep=True e definido, o metodo executa etapas adicionais:
Tab.find_shadow_roots(deep=True)
├─ ... (travessia do documento principal como acima) ...
└─ _collect_oopif_shadow_roots()
├─ ConnectionHandler de nivel browser (sem page_id → endpoint do browser)
├─ Target.getTargets() → filtrar type='iframe'
└─ Para cada target iframe:
├─ Target.attachToTarget(targetId, flatten=True) → sessionId
├─ DOM.getDocument(depth=-1, pierce=True) com sessionId
├─ _collect_shadow_roots_from_tree() no DOM do OOPIF
└─ Para cada shadow root encontrado:
├─ DOM.resolveNode(backendNodeId) com sessionId
├─ Resolver elemento host (melhor esforco) com sessionId
├─ Criar IFrameContext(frame_id, session_handler, session_id)
└─ Definir IFrameContext no elemento host (ou diretamente no ShadowRoot)
Os objetos ShadowRoot retornados carregam o contexto de roteamento OOPIF (IFrameContext), entao elementos encontrados via shadow_root.query() roteiam automaticamente comandos CDP pela sessao OOPIF correta. Isso e critico para cenarios como captchas Cloudflare Turnstile, onde o checkbox esta dentro de um shadow root fechado dentro de um iframe cross-origin.
Limitacoes e Casos Especiais
Estrategias de Seletores Dentro de Shadow Roots
Use Apenas query() com CSS Dentro de Shadow Roots
ShadowRoot define _css_only = True, o que significa que apenas query() com seletores CSS e suportado. find() e query() com XPath lancam NotImplementedError.
Shadow roots implementam nativamente querySelector() e querySelectorAll(), tornando seletores CSS a escolha natural e confiavel:
| Metodo | Dentro do Shadow Root | Notas |
|---|---|---|
query('seletor-css') |
Totalmente suportado | Abordagem recomendada |
query('seletor-css', find_all=True) |
Totalmente suportado | Retorna lista de elementos |
find() |
Nao suportado | Lanca NotImplementedError |
query('//xpath') |
Nao suportado | Lanca NotImplementedError |
shadow = await host.get_shadow_root()
# ✓ Recomendado: query() com seletores CSS
button = await shadow.query('button.submit')
email = await shadow.query('#email-input')
items = await shadow.query('.item', find_all=True)
# ✗ Nao suportado: find() e XPath lancam NotImplementedError
# shadow.find(id='email-input') # NotImplementedError
# shadow.query('//button') # NotImplementedError
XPath Nao Cruza Fronteiras do Shadow
Expressoes XPath a partir da raiz do documento nao conseguem atravessar fronteiras do shadow. Esta e uma limitacao fundamental do XPath, que foi projetado antes do Shadow DOM existir:
# Nao encontra conteudo shadow: XPath no nivel do documento nao cruza a fronteira
element = await tab.find(xpath='//div[@id="host"]//button')
Shadow Roots User-Agent
Shadow roots internos do navegador (ex: estilizacao de placeholder de <input>, controles de <video>) sao do tipo user-agent. Eles sao acessiveis via CDP, mas sua estrutura interna varia entre versoes do navegador e nao faz parte de nenhum padrao web.
input_element = await tab.find(tag_name='input')
try:
ua_shadow = await input_element.get_shadow_root()
# ua_shadow.mode == ShadowRootType.USER_AGENT
# Estrutura interna e especifica do navegador
except ShadowRootNotFound:
pass # Nem todos os inputs tem shadow roots user-agent
Estabilidade de Shadow Roots User-Agent
Nao construa logica de automacao que dependa da estrutura interna de shadow roots user-agent. Sua estrutura DOM e um detalhe de implementacao que pode mudar entre versoes do navegador sem aviso.
Referencias de Shadow Root Obsoletas
Se o elemento host for removido do DOM e re-adicionado (comum em aplicacoes single-page), o objectId do shadow root se torna obsoleto. A solucao e re-adquirir o shadow root:
# Apos uma navegacao de pagina ou reconstrucao do DOM:
host = await tab.find(id='my-component', timeout=5) # Re-encontrar o host
shadow = await host.get_shadow_root() # Shadow root atualizado
Pontos-Chave
- Encapsulamento Shadow DOM oculta elementos do
querySelector()no nivel do documento, quebrando automacao tradicional - CDP opera abaixo da camada de API JavaScript, contornando restricoes de modo do shadow completamente
backendNodeIde o identificador estavel usado para resolucao de shadow root, evitando a necessidade de habilitar o dominio DOMShadowRootherdaFindElementsMixincom_css_only = True, suportando apenasquery()com seletores CSS;find()e XPath lancamNotImplementedError- Shadow roots fechados sao totalmente acessiveis porque o modo
closede uma politica no nivel JavaScript, nao uma restricao no nivel DOM - Shadow roots aninhados funcionam naturalmente encadeando chamadas
get_shadow_root()em cada nivel - Shadow roots dentro de iframes funcionam de forma transparente atraves da propagacao automatica de contexto do iframe
- Use seletores CSS (
query()) dentro de shadow roots;find()e XPath nao sao suportados find_shadow_roots()descobre todos os shadow roots na pagina; suportatimeoutpara polling edeep=Truepara iframes cross-origin (OOPIFs)get_shadow_root(timeout)espera pelo shadow root de um elemento especifico
Documentacao Relacionada
- Guia de Pesquisa de Elementos: Uso pratico de
find(),query(), e acesso a shadow root - IFrames e Contextos: Como o Pydoll resolve e roteia comandos para iframes, incluindo tratamento de OOPIF
- Arquitetura do FindElements Mixin: Como o mecanismo
_object_idhabilita buscas com escopo - Dominio WebElement: Como elementos interagem com CDP
- Camada de Conexao: Comunicacao WebSocket com o navegador