8742cb25be
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
177 lines
5.2 KiB
Go
177 lines
5.2 KiB
Go
package browser
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// frameCtxCache mapea frameID -> executionContextId del isolated world creado
|
|
// para ese frame. Evita pagar Page.createIsolatedWorld en cada CdpEvalInFrame.
|
|
// Es puro y testeable de forma aislada (su propio mutex, sin tocar CDP).
|
|
type frameCtxCache struct {
|
|
mu sync.Mutex
|
|
m map[string]int
|
|
}
|
|
|
|
func newFrameCtxCache() *frameCtxCache {
|
|
return &frameCtxCache{m: map[string]int{}}
|
|
}
|
|
|
|
func (f *frameCtxCache) get(frameID string) (int, bool) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
id, ok := f.m[frameID]
|
|
return id, ok
|
|
}
|
|
|
|
func (f *frameCtxCache) set(frameID string, ctxID int) {
|
|
f.mu.Lock()
|
|
f.m[frameID] = ctxID
|
|
f.mu.Unlock()
|
|
}
|
|
|
|
func (f *frameCtxCache) invalidate(frameID string) {
|
|
f.mu.Lock()
|
|
delete(f.m, frameID)
|
|
f.mu.Unlock()
|
|
}
|
|
|
|
// isStaleContextError reconoce el error de CDP cuando un executionContextId
|
|
// cacheado ya no existe (el frame recargó/navegó y su isolated world murió). Es
|
|
// puro: decide a partir del texto del error. Permite reintentar recreando el
|
|
// mundo en vez de fallar.
|
|
func isStaleContextError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
s := err.Error()
|
|
return strings.Contains(s, "Cannot find context") ||
|
|
strings.Contains(s, "context with specified id") ||
|
|
strings.Contains(s, "Execution context was destroyed") ||
|
|
strings.Contains(s, "uniqueContextId")
|
|
}
|
|
|
|
// frameCtxCacheLazy devuelve el cache de contextos del frame de esta conexion,
|
|
// inicializandolo en el primer uso. El mutex de CDPConn solo protege este
|
|
// lazy-init del puntero.
|
|
func (c *CDPConn) frameCtxCacheLazy() *frameCtxCache {
|
|
c.frameCtxMu.Lock()
|
|
defer c.frameCtxMu.Unlock()
|
|
if c.frameCtx == nil {
|
|
c.frameCtx = newFrameCtxCache()
|
|
}
|
|
return c.frameCtx
|
|
}
|
|
|
|
// createIsolatedWorld crea un mundo aislado en el frame y devuelve su
|
|
// executionContextId.
|
|
func createIsolatedWorld(c *CDPConn, frameID string) (int, error) {
|
|
ctxRes, err := c.sendCDP("Page.createIsolatedWorld", map[string]any{
|
|
"frameId": frameID,
|
|
"worldName": "fn_registry_isolated",
|
|
"grantUniversalAccess": false,
|
|
})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("createIsolatedWorld: %w", err)
|
|
}
|
|
ctxIDRaw, ok := ctxRes["executionContextId"]
|
|
if !ok {
|
|
return 0, fmt.Errorf("createIsolatedWorld: executionContextId no encontrado en respuesta")
|
|
}
|
|
ctxID, ok := ctxIDRaw.(float64)
|
|
if !ok {
|
|
return 0, fmt.Errorf("createIsolatedWorld: executionContextId tipo inesperado: %T", ctxIDRaw)
|
|
}
|
|
return int(ctxID), nil
|
|
}
|
|
|
|
// evalInFrameContext ejecuta la expresion en el executionContextId dado y
|
|
// serializa el resultado como string (mismo patron que CdpEvaluate).
|
|
func evalInFrameContext(c *CDPConn, ctxID int, frameID, expression string) (string, error) {
|
|
evRes, err := c.sendCDP("Runtime.evaluate", map[string]any{
|
|
"expression": expression,
|
|
"contextId": ctxID,
|
|
"returnByValue": true,
|
|
"awaitPromise": true,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("Runtime.evaluate: %w", err)
|
|
}
|
|
|
|
if exc, ok := evRes["exceptionDetails"]; ok && exc != nil {
|
|
excMap, _ := exc.(map[string]any)
|
|
text, _ := excMap["text"].(string)
|
|
return "", fmt.Errorf("excepcion JS en frame %q: %s", frameID, text)
|
|
}
|
|
|
|
resVal, ok := evRes["result"].(map[string]any)
|
|
if !ok {
|
|
return "", fmt.Errorf("resultado inesperado: %v", evRes)
|
|
}
|
|
|
|
value, ok := resVal["value"]
|
|
if !ok {
|
|
typ, _ := resVal["type"].(string)
|
|
return typ, nil
|
|
}
|
|
if s, ok := value.(string); ok {
|
|
return s, nil
|
|
}
|
|
b, err := json.Marshal(value)
|
|
if err != nil {
|
|
return fmt.Sprintf("%v", value), nil
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
// CdpEvalInFrame ejecuta una expresion JavaScript en el contexto aislado de un iframe
|
|
// especifico usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame.
|
|
// Retorna el resultado serializado como string.
|
|
//
|
|
// Cachea el executionContextId por frameID en la conexion: la primera llamada
|
|
// crea el mundo aislado, las siguientes lo reutilizan. Si el contexto cacheado
|
|
// caducó (el frame navegó/recargó), recrea el mundo una vez y reintenta.
|
|
func CdpEvalInFrame(c *CDPConn, frameID, expression string) (string, error) {
|
|
if c == nil {
|
|
return "", fmt.Errorf("cdp eval in frame: conexion nula")
|
|
}
|
|
if frameID == "" {
|
|
return "", fmt.Errorf("cdp eval in frame: frameID vacio")
|
|
}
|
|
|
|
// Page.enable es idempotente; necesario antes de crear mundos aislados.
|
|
if _, err := c.sendCDP("Page.enable", nil); err != nil {
|
|
return "", fmt.Errorf("cdp eval in frame: Page.enable: %w", err)
|
|
}
|
|
|
|
cache := c.frameCtxCacheLazy()
|
|
|
|
ctxID, cached := cache.get(frameID)
|
|
if !cached {
|
|
newID, err := createIsolatedWorld(c, frameID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cdp eval in frame: %w", err)
|
|
}
|
|
ctxID = newID
|
|
cache.set(frameID, ctxID)
|
|
}
|
|
|
|
out, evErr := evalInFrameContext(c, ctxID, frameID, expression)
|
|
if evErr != nil && cached && isStaleContextError(evErr) {
|
|
// El contexto cacheado murió (frame recargó). Recrear una vez.
|
|
cache.invalidate(frameID)
|
|
newID, err := createIsolatedWorld(c, frameID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cdp eval in frame: %w", err)
|
|
}
|
|
cache.set(frameID, newID)
|
|
out, evErr = evalInFrameContext(c, newID, frameID, expression)
|
|
}
|
|
if evErr != nil {
|
|
return "", fmt.Errorf("cdp eval in frame: %w", evErr)
|
|
}
|
|
return out, nil
|
|
}
|