Files
nyoungo 18b2c04006 initial import
- Go service bridging deepseek.de/es/fr V4 models behind an OpenAI-compatible API
- /v1/chat/completions, /v1/responses, /v1/messages (Anthropic) with tool calling
- web admin console: dashboard, models/sites, keys, proxies, Turnstile, playground, live logs
- Turnstile solvers: solver API / local browser via CDP / manual cookie import
- MIT license, README with screenshots, Dockerfile and Makefile
2026-10-07 15:46:45 +08:00

231 lines
5.6 KiB
Go

package turnstile
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"golang.org/x/net/websocket"
)
// cdpEventHook, when set (tests / troubleshooting), receives every protocol
// event. Production leaves it nil and events are dropped.
var cdpEventHook func(session, method string, params json.RawMessage)
// subTargetEvent is a Target.attachedToTarget / Target.detachedFromTarget
// notification routed to a subscriber (the headless sub-frame screen fix).
type subTargetEvent struct {
session string
method string
params json.RawMessage
}
// cdp is a minimal Chrome DevTools Protocol client: JSON request/response over
// a single WebSocket. The solver only issues calls and polls, which keeps the
// client small and race-free.
type cdp struct {
conn *websocket.Conn
writeMu sync.Mutex
mu sync.Mutex
seq int64
pending map[int64]chan cdpResult
err error
subCh chan subTargetEvent
}
type cdpResult struct {
Result json.RawMessage
Error *cdpCallError
}
type cdpCallError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e *cdpCallError) Error() string {
return fmt.Sprintf("cdp error %d: %s", e.Code, e.Message)
}
// dialCDP opens the DevTools WebSocket (origin must match the browser host,
// otherwise Chrome rejects the handshake).
func dialCDP(ctx context.Context, wsURL, origin string) (*cdp, error) {
cfg, err := websocket.NewConfig(wsURL, origin)
if err != nil {
return nil, err
}
conn, err := cfg.DialContext(ctx)
if err != nil {
return nil, err
}
c := &cdp{conn: conn, pending: map[int64]chan cdpResult{}}
go c.readLoop()
return c, nil
}
func (c *cdp) readLoop() {
for {
var raw string
if err := websocket.Message.Receive(c.conn, &raw); err != nil {
c.fail(fmt.Errorf("cdp connection lost: %w", err))
return
}
var msg struct {
ID int64 `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
SessionID string `json:"sessionId"`
Result json.RawMessage `json:"result"`
Error *cdpCallError `json:"error"`
}
if err := json.Unmarshal([]byte(raw), &msg); err != nil {
continue
}
if msg.ID == 0 {
if msg.Method != "" {
if h := cdpEventHook; h != nil {
h(msg.SessionID, msg.Method, msg.Params)
}
if msg.Method == "Target.attachedToTarget" || msg.Method == "Target.detachedFromTarget" {
c.mu.Lock()
ch := c.subCh
c.mu.Unlock()
if ch != nil {
select {
case ch <- subTargetEvent{msg.SessionID, msg.Method, msg.Params}:
default: // subscriber is behind; never block the read loop
}
}
}
}
continue // events and target-scoped notifications
}
c.mu.Lock()
ch, ok := c.pending[msg.ID]
delete(c.pending, msg.ID)
c.mu.Unlock()
if ok {
ch <- cdpResult{Result: msg.Result, Error: msg.Error}
}
}
}
func (c *cdp) fail(err error) {
c.mu.Lock()
if c.err == nil {
c.err = err
}
pending := c.pending
c.pending = map[int64]chan cdpResult{}
c.mu.Unlock()
for _, ch := range pending {
ch <- cdpResult{Error: &cdpCallError{Message: err.Error()}}
}
}
// call sends one CDP command, optionally scoped to an attached target session.
func (c *cdp) call(ctx context.Context, session, method string, params any) (json.RawMessage, error) {
c.mu.Lock()
if c.err != nil {
err := c.err
c.mu.Unlock()
return nil, err
}
c.seq++
id := c.seq
ch := make(chan cdpResult, 1)
c.pending[id] = ch
c.mu.Unlock()
msg := map[string]any{"id": id, "method": method}
if params != nil {
msg["params"] = params
}
if session != "" {
msg["sessionId"] = session
}
buf, err := json.Marshal(msg)
if err != nil {
c.drop(id)
return nil, err
}
c.writeMu.Lock()
sendErr := websocket.Message.Send(c.conn, string(buf))
c.writeMu.Unlock()
if sendErr != nil {
c.drop(id)
return nil, fmt.Errorf("cdp send %s: %w", method, sendErr)
}
select {
case <-ctx.Done():
c.drop(id)
return nil, ctx.Err()
case res := <-ch:
if res.Error != nil {
return nil, res.Error
}
return res.Result, nil
}
}
func (c *cdp) drop(id int64) {
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
}
// setSubTargets installs (nil clears) the sub-target event sink. Install it
// before Target.setAutoAttach so no attach notification can be lost.
func (c *cdp) setSubTargets(ch chan subTargetEvent) {
c.mu.Lock()
c.subCh = ch
c.mu.Unlock()
}
func (c *cdp) Close() { _ = c.conn.Close() }
type evalResult struct {
Result struct {
Type string `json:"type"`
Value json.RawMessage `json:"value"`
} `json:"result"`
ExceptionDetails *struct {
Text string `json:"text"`
Exception *struct {
Description string `json:"description"`
} `json:"exception"`
} `json:"exceptionDetails"`
}
// evaluate runs an expression in the page and returns its JSON value. The
// 15s cap keeps a wedged browser from blocking a chat request forever.
func (c *cdp) evaluate(ctx context.Context, session, expr string) (json.RawMessage, error) {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
raw, err := c.call(ctx, session, "Runtime.evaluate", map[string]any{
"expression": expr,
"returnByValue": true,
"awaitPromise": true,
"userGesture": true,
})
if err != nil {
return nil, err
}
var out evalResult
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("runtime.evaluate bad json: %w", err)
}
if d := out.ExceptionDetails; d != nil {
msg := d.Text
if d.Exception != nil && d.Exception.Description != "" {
msg = d.Exception.Description
}
return nil, fmt.Errorf("javascript error: %s", msg)
}
return out.Result.Value, nil
}