feat: auth-capture — WS frame stream, drag events, continuous CDP, profile cleanup
- AuthCaptureDialog: real WebSocket for binary JPEG frame stream (no polling) - Pointer drag: mousedown/mousemove/mouseup events for slider-captcha - CDP capture starts at session creation, caches headers in session.captured_headers - Ephemeral profile dir deleted on session close (shutil.rmtree) - Candidate types unified: bearer_token / cookie / api_key / credential - Frontend handleAuthCaptureSelect maps all 4 types to correct form fields
This commit is contained in:
@@ -59,51 +59,17 @@ async def extract_session_storage(page: Any) -> dict[str, str]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
async def extract_request_headers(page: Any) -> list[dict[str, str]]:
|
async def extract_request_headers(session: Any) -> list[dict[str, str]]:
|
||||||
"""Capture Authorization headers from network requests via CDP.
|
"""Return Authorization / API-Key headers captured continuously by CDP.
|
||||||
|
|
||||||
Uses Chrome DevTools Protocol to subscribe to Network.requestWillBeSent
|
The CDP Network listener is started when the ephemeral session is created
|
||||||
events and extract Authorization / X-API-Key headers from captured
|
(in BrowserSessionService.create_ephemeral), so headers from the login
|
||||||
requests. Only catches requests made *after* CDP is enabled.
|
flow are captured in real-time without needing a fresh CDP attach.
|
||||||
"""
|
"""
|
||||||
captured: list[dict[str, str]] = []
|
if hasattr(session, "captured_headers") and session.captured_headers:
|
||||||
cdp = None
|
logger.debug("auth-capture: returning %d cached headers", len(session.captured_headers))
|
||||||
try:
|
return list(session.captured_headers)
|
||||||
cdp = await page.context.new_cdp_session(page)
|
return []
|
||||||
await cdp.send("Network.enable")
|
|
||||||
|
|
||||||
def on_request(params: dict) -> None:
|
|
||||||
headers = params.get("request", {}).get("headers", {})
|
|
||||||
auth = (headers.get("authorization") or headers.get("Authorization"))
|
|
||||||
api_key = (headers.get("x-api-key") or headers.get("X-API-Key"))
|
|
||||||
if auth:
|
|
||||||
captured.append({
|
|
||||||
"type": "authorization",
|
|
||||||
"value": auth,
|
|
||||||
"url": params.get("request", {}).get("url", ""),
|
|
||||||
})
|
|
||||||
logger.debug("auth-capture CDP: captured Authorization header")
|
|
||||||
if api_key:
|
|
||||||
captured.append({
|
|
||||||
"type": "api_key",
|
|
||||||
"value": api_key,
|
|
||||||
"url": params.get("request", {}).get("url", ""),
|
|
||||||
})
|
|
||||||
logger.debug("auth-capture CDP: captured X-API-Key header")
|
|
||||||
|
|
||||||
cdp.on("Network.requestWillBeSent", on_request)
|
|
||||||
# Give a moment for any in-flight requests to be captured
|
|
||||||
import asyncio
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("CDP network capture not available: %s", exc)
|
|
||||||
finally:
|
|
||||||
if cdp:
|
|
||||||
try:
|
|
||||||
await cdp.detach()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return captured
|
|
||||||
|
|
||||||
|
|
||||||
async def extract_all(session: Any) -> dict[str, Any]:
|
async def extract_all(session: Any) -> dict[str, Any]:
|
||||||
@@ -116,7 +82,7 @@ async def extract_all(session: Any) -> dict[str, Any]:
|
|||||||
cookies = await extract_cookies(session)
|
cookies = await extract_cookies(session)
|
||||||
local_storage = await extract_local_storage(page)
|
local_storage = await extract_local_storage(page)
|
||||||
session_storage = await extract_session_storage(page)
|
session_storage = await extract_session_storage(page)
|
||||||
auth_headers = await extract_request_headers(page)
|
auth_headers = await extract_request_headers(session)
|
||||||
candidates = _curate_candidates(cookies, local_storage, session_storage, auth_headers)
|
candidates = _curate_candidates(cookies, local_storage, session_storage, auth_headers)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -137,22 +103,21 @@ def _curate_candidates(
|
|||||||
"""Scan extracted data for likely credentials with confidence scoring."""
|
"""Scan extracted data for likely credentials with confidence scoring."""
|
||||||
candidates: list[dict[str, Any]] = []
|
candidates: list[dict[str, Any]] = []
|
||||||
|
|
||||||
# 1. CDP-captured Authorization headers (highest confidence)
|
# 1. CDP-captured network headers (highest confidence)
|
||||||
seen = set()
|
seen = set()
|
||||||
for h in auth_headers:
|
for h in auth_headers:
|
||||||
dedup_key = h["value"]
|
dedup_key = h["value"]
|
||||||
if dedup_key in seen:
|
if dedup_key in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(dedup_key)
|
seen.add(dedup_key)
|
||||||
|
htype = h.get("type", "authorization")
|
||||||
preview = _preview(h["value"])
|
preview = _preview(h["value"])
|
||||||
candidates.append({
|
if htype == "api_key":
|
||||||
"type": "bearer_token",
|
_add(candidates, "api_key", f"network:{h['url'][:60]}", h["value"], preview,
|
||||||
"source": f"network:{h['url'][:60]}",
|
f"X-API-Key — {h['url'][:40]}", 95)
|
||||||
"value": h["value"],
|
else:
|
||||||
"preview": preview,
|
_add(candidates, "bearer_token", f"network:{h['url'][:60]}", h["value"], preview,
|
||||||
"label": f"Authorization — {h['url'][:40]}",
|
f"Authorization — {h['url'][:40]}", 95)
|
||||||
"confidence": 95,
|
|
||||||
})
|
|
||||||
|
|
||||||
# 2. localStorage/sessionStorage items
|
# 2. localStorage/sessionStorage items
|
||||||
for store_name, store in [("localStorage", local_storage), ("sessionStorage", session_storage)]:
|
for store_name, store in [("localStorage", local_storage), ("sessionStorage", session_storage)]:
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ class BrowserSession:
|
|||||||
context: Any
|
context: Any
|
||||||
page: Any
|
page: Any
|
||||||
lock: asyncio.Lock
|
lock: asyncio.Lock
|
||||||
|
cdp_session: Any = None
|
||||||
|
captured_headers: list[dict] = None # auth headers from CDP
|
||||||
|
|
||||||
|
|
||||||
class BrowserSessionService:
|
class BrowserSessionService:
|
||||||
@@ -163,10 +165,24 @@ class BrowserSessionService:
|
|||||||
session = self._discard_session(session_id)
|
session = self._discard_session(session_id)
|
||||||
if not session:
|
if not session:
|
||||||
return
|
return
|
||||||
|
# Detach CDP session if active
|
||||||
|
if session.cdp_session:
|
||||||
|
try:
|
||||||
|
await session.cdp_session.detach()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
await session.context.close()
|
await session.context.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# Clean up ephemeral (auth-capture) profile directories
|
||||||
|
if session.profile_key and session.profile_key.startswith("auth-capture-"):
|
||||||
|
profile_dir = self._profile_dir(session.profile_key)
|
||||||
|
import shutil
|
||||||
|
try:
|
||||||
|
shutil.rmtree(profile_dir, ignore_errors=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
async def shutdown(self) -> None:
|
async def shutdown(self) -> None:
|
||||||
sessions = list(self._sessions)
|
sessions = list(self._sessions)
|
||||||
@@ -368,14 +384,46 @@ class BrowserSessionService:
|
|||||||
context=context,
|
context=context,
|
||||||
page=page,
|
page=page,
|
||||||
lock=asyncio.Lock(),
|
lock=asyncio.Lock(),
|
||||||
|
captured_headers=[],
|
||||||
)
|
)
|
||||||
self._sessions[session.id] = session
|
self._sessions[session.id] = session
|
||||||
try:
|
try:
|
||||||
await page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
await page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
||||||
|
# Start CDP network capture immediately — so we don't miss login requests
|
||||||
|
await self._start_cdp_capture(session)
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.close(session.id)
|
await self.close(session.id)
|
||||||
raise
|
raise
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
async def _start_cdp_capture(self, session: BrowserSession) -> None:
|
||||||
|
"""Enable CDP Network domain and capture Authorization headers."""
|
||||||
|
try:
|
||||||
|
cdp = await session.context.new_cdp_session(session.page)
|
||||||
|
await cdp.send("Network.enable")
|
||||||
|
|
||||||
|
def on_request(params: dict) -> None:
|
||||||
|
headers = params.get("request", {}).get("headers", {})
|
||||||
|
auth = headers.get("authorization") or headers.get("Authorization")
|
||||||
|
api_key = headers.get("x-api-key") or headers.get("X-API-Key")
|
||||||
|
url = params.get("request", {}).get("url", "")
|
||||||
|
if auth:
|
||||||
|
session.captured_headers.append({
|
||||||
|
"type": "authorization",
|
||||||
|
"value": auth,
|
||||||
|
"url": url,
|
||||||
|
})
|
||||||
|
if api_key:
|
||||||
|
session.captured_headers.append({
|
||||||
|
"type": "api_key",
|
||||||
|
"value": api_key,
|
||||||
|
"url": url,
|
||||||
|
})
|
||||||
|
|
||||||
|
cdp.on("Network.requestWillBeSent", on_request)
|
||||||
|
session.cdp_session = cdp
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("CDP capture not available: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
browser_sessions = BrowserSessionService()
|
browser_sessions = BrowserSessionService()
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ export interface AuthCaptureResult {
|
|||||||
session_storage: Record<string, string>
|
session_storage: Record<string, string>
|
||||||
auth_headers: Record<string, string>[]
|
auth_headers: Record<string, string>[]
|
||||||
candidates: {
|
candidates: {
|
||||||
type: 'bearer_token' | 'cookie' | 'credential'
|
type: 'bearer_token' | 'cookie' | 'credential' | 'api_key'
|
||||||
source: string
|
source: string
|
||||||
value: string
|
value: string
|
||||||
preview: string
|
preview: string
|
||||||
|
|||||||
@@ -15,22 +15,13 @@
|
|||||||
<el-form-item label="登录页 URL">
|
<el-form-item label="登录页 URL">
|
||||||
<el-input v-model="targetUrl" placeholder="https://example.com/auth/login" />
|
<el-input v-model="targetUrl" placeholder="https://example.com/auth/login" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div class="capture-extra-fields" v-if="showExtraFields">
|
<div v-if="showExtraFields" class="capture-extra-fields">
|
||||||
<el-form-item label="登录账号">
|
<el-form-item label="登录账号">
|
||||||
<el-input v-model="loginUsername" placeholder="用于自动填充(可选)" />
|
<el-input v-model="loginUsername" placeholder="用于自动填充(可选)" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="登录密码">
|
<el-form-item label="登录密码">
|
||||||
<el-input v-model="loginPassword" type="password" placeholder="用于自动填充(可选)" />
|
<el-input v-model="loginPassword" type="password" placeholder="用于自动填充(可选)" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="用户名选择器">
|
|
||||||
<el-input v-model="usernameSelector" placeholder="input[name=email]" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="密码选择器">
|
|
||||||
<el-input v-model="passwordSelector" placeholder="input[name=password]" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="提交按钮选择器">
|
|
||||||
<el-input v-model="submitSelector" placeholder="button[type=submit]" />
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="capture-launch-row">
|
<div class="capture-launch-row">
|
||||||
<el-button text size="small" @click="showExtraFields = !showExtraFields">
|
<el-button text size="small" @click="showExtraFields = !showExtraFields">
|
||||||
@@ -44,18 +35,18 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 2: Interactive browser + manual login -->
|
<!-- Step 2: Interactive browser via WebSocket -->
|
||||||
<div v-else class="capture-step">
|
<div v-else class="capture-step">
|
||||||
<div class="capture-step-header">
|
<div class="capture-step-header">
|
||||||
<h4>步骤 2:在浏览器中手动登录</h4>
|
<h4>步骤 2:在浏览器中手动登录</h4>
|
||||||
<div class="capture-actions">
|
<div class="capture-actions">
|
||||||
<el-button size="small" @click="sendEvent('back')" :disabled="!sessionId">
|
<el-button size="small" @click="wsSend({type:'back'})" :disabled="!wsConnected">
|
||||||
<el-icon><Back /></el-icon>
|
<el-icon><Back /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button size="small" @click="sendEvent('forward')" :disabled="!sessionId">
|
<el-button size="small" @click="wsSend({type:'forward'})" :disabled="!wsConnected">
|
||||||
<el-icon><Right /></el-icon>
|
<el-icon><Right /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button size="small" @click="sendEvent('reload')" :disabled="!sessionId">
|
<el-button size="small" @click="wsSend({type:'reload'})" :disabled="!wsConnected">
|
||||||
<el-icon><Refresh /></el-icon>
|
<el-icon><Refresh /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-divider direction="vertical" />
|
<el-divider direction="vertical" />
|
||||||
@@ -64,39 +55,43 @@
|
|||||||
提取认证信息
|
提取认证信息
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ws-status">
|
||||||
|
<span :class="['ws-dot', wsConnected ? 'connected' : 'disconnected']" />
|
||||||
|
{{ wsConnected ? '实时' : '连接中…' }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="capture-hint">在下方浏览器中完成登录后,点击「提取认证信息」获取 token / cookie。</p>
|
<p class="capture-hint">在下方浏览器中完成登录后,点击「提取认证信息」获取 token / cookie。</p>
|
||||||
|
|
||||||
<!-- Interactive browser frame -->
|
<!-- WS-based interactive frame -->
|
||||||
<div
|
<div
|
||||||
ref="browserFrameRef"
|
ref="frameRef"
|
||||||
class="browser-viewport"
|
class="browser-viewport"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
:class="{ 'ws-connected': wsConnected }"
|
||||||
@keydown.prevent="onKeydown"
|
@keydown.prevent="onKeydown"
|
||||||
@wheel.prevent="onWheel"
|
@wheel.prevent="onWheel"
|
||||||
@pointerdown.stop.prevent="onPointerDown"
|
@pointerdown.stop.prevent="onPointerDown"
|
||||||
@pointermove.stop.prevent="onPointerMove"
|
@pointermove.stop.prevent="onPointerMove"
|
||||||
@pointerup.stop.prevent="onPointerUp"
|
@pointerup.stop.prevent="onPointerUp"
|
||||||
@pointercancel.stop.prevent="onPointerCancel"
|
@pointercancel.stop.prevent="onPointerUp"
|
||||||
@dblclick.prevent="onDblClick"
|
@dblclick.prevent="onDblClick"
|
||||||
@contextmenu.prevent
|
@contextmenu.prevent
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="screenshotUrl"
|
v-if="frameUrl"
|
||||||
:src="screenshotUrl"
|
:src="frameUrl"
|
||||||
class="browser-frame"
|
class="browser-frame"
|
||||||
alt="远程浏览器"
|
alt="远程浏览器"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
@load="onScreenshotLoaded"
|
@load="onFrameLoad"
|
||||||
@error="onScreenshotError"
|
|
||||||
/>
|
/>
|
||||||
<div v-else class="browser-loading">
|
<div v-else class="browser-loading">
|
||||||
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
|
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
|
||||||
<p>正在启动浏览器…</p>
|
<p>正在连接远程浏览器…</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Extraction results (overlay after extract) -->
|
<!-- Extraction results panel -->
|
||||||
<transition name="el-zoom-in-top">
|
<transition name="el-zoom-in-top">
|
||||||
<div v-if="extracted && result" class="candidate-panel">
|
<div v-if="extracted && result" class="candidate-panel">
|
||||||
<div class="candidate-panel-header">
|
<div class="candidate-panel-header">
|
||||||
@@ -117,16 +112,16 @@
|
|||||||
<div class="candidate-row">
|
<div class="candidate-row">
|
||||||
<el-radio :model-value="selectedIndex === i" :label="i" @click.stop="selectedIndex = i">
|
<el-radio :model-value="selectedIndex === i" :label="i" @click.stop="selectedIndex = i">
|
||||||
<span class="candidate-badge" :class="c.type || 'credential'">
|
<span class="candidate-badge" :class="c.type || 'credential'">
|
||||||
{{ c.type === 'bearer_token' ? 'Bearer' : c.type === 'cookie' ? 'Cookie' : 'Key' }}
|
{{ badgeLabel(c.type) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="candidate-label">{{ c.label }}</span>
|
<span class="candidate-label">{{ c.label }}</span>
|
||||||
</el-radio>
|
</el-radio>
|
||||||
<span v-if="c.confidence" class="candidate-confidence" :class="confidenceClass(c.confidence)">
|
<span v-if="c.confidence" class="candidate-confidence" :class="confClass(c.confidence)">
|
||||||
{{ c.confidence }}%
|
{{ c.confidence }}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="candidate-preview">
|
<div class="candidate-preview">
|
||||||
<code>{{ maskValue(c.preview || c.value) }}</code>
|
<code>{{ c.preview || maskValue(c.value) }}</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,7 +139,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, nextTick } from 'vue'
|
import { ref, watch, onUnmounted, nextTick } from 'vue'
|
||||||
import { Pointer, Search, Back, Right, Refresh, Loading } from '@element-plus/icons-vue'
|
import { Pointer, Search, Back, Right, Refresh, Loading } from '@element-plus/icons-vue'
|
||||||
import { authCaptureApi, browserSessionsApi, type AuthCaptureResult } from '@/api'
|
import { authCaptureApi, browserSessionsApi, type AuthCaptureResult } from '@/api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -166,27 +161,27 @@ watch(() => props.modelValue, (v) => { visible.value = v })
|
|||||||
const targetUrl = ref(props.initialUrl || '')
|
const targetUrl = ref(props.initialUrl || '')
|
||||||
watch(() => props.initialUrl, (v) => { if (v) targetUrl.value = v })
|
watch(() => props.initialUrl, (v) => { if (v) targetUrl.value = v })
|
||||||
|
|
||||||
// Auto-fill fields
|
// Auto-fill
|
||||||
const showExtraFields = ref(false)
|
const showExtraFields = ref(false)
|
||||||
const loginUsername = ref('')
|
const loginUsername = ref('')
|
||||||
const loginPassword = ref('')
|
const loginPassword = ref('')
|
||||||
const usernameSelector = ref('input[name="email"],input[name="username"],input[type="email"]')
|
|
||||||
const passwordSelector = ref('input[name="password"],input[type="password"]')
|
|
||||||
const submitSelector = ref('button[type="submit"],input[type="submit"]')
|
|
||||||
|
|
||||||
// Session
|
// Session + WS
|
||||||
const sessionId = ref('')
|
const sessionId = ref('')
|
||||||
const launching = ref(false)
|
const launching = ref(false)
|
||||||
const extracting = ref(false)
|
const extracting = ref(false)
|
||||||
const extracted = ref(false)
|
const extracted = ref(false)
|
||||||
const result = ref<AuthCaptureResult | null>(null)
|
const result = ref<AuthCaptureResult | null>(null)
|
||||||
const selectedIndex = ref(-1)
|
const selectedIndex = ref(-1)
|
||||||
const screenshotUrl = ref('')
|
const wsConnected = ref(false)
|
||||||
const browserFrameRef = ref<HTMLElement | null>(null)
|
const frameUrl = ref('')
|
||||||
|
const frameRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
// Pointer tracking for mouse events
|
let ws: WebSocket | null = null
|
||||||
const pointerPos = ref({ x: 0, y: 0 })
|
let pointerDown = false
|
||||||
const imgNaturalSize = ref({ w: 0, h: 0 })
|
let frameW = 1; let frameH = 1 // natural dimensions of the frame
|
||||||
|
|
||||||
|
// ——— Launch ———
|
||||||
|
|
||||||
async function launchBrowser() {
|
async function launchBrowser() {
|
||||||
if (!targetUrl.value) return
|
if (!targetUrl.value) return
|
||||||
@@ -194,28 +189,8 @@ async function launchBrowser() {
|
|||||||
try {
|
try {
|
||||||
const res = await authCaptureApi.createSession(targetUrl.value)
|
const res = await authCaptureApi.createSession(targetUrl.value)
|
||||||
sessionId.value = res.data.session_id
|
sessionId.value = res.data.session_id
|
||||||
screenshotUrl.value = buildScreenshotUrl()
|
await nextTick()
|
||||||
|
connectWs()
|
||||||
// Auto-fill login form after page loads
|
|
||||||
if (loginUsername.value || loginPassword.value) {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (loginUsername.value) {
|
|
||||||
browserSessionsApi.event(sessionId.value, {
|
|
||||||
type: 'type',
|
|
||||||
text: loginUsername.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// Focus password field via tab
|
|
||||||
setTimeout(() => {
|
|
||||||
if (loginPassword.value) {
|
|
||||||
browserSessionsApi.event(sessionId.value, {
|
|
||||||
type: 'type',
|
|
||||||
text: loginPassword.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, 300)
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('launch failed', e)
|
console.error('launch failed', e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -223,87 +198,108 @@ async function launchBrowser() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildScreenshotUrl() {
|
// ——— WebSocket frame stream ———
|
||||||
|
|
||||||
|
function connectWs() {
|
||||||
const token = auth.token
|
const token = auth.token
|
||||||
return browserSessionsApi.screenshotUrl(sessionId.value, token) + '&t=' + Date.now()
|
if (!token || !sessionId.value) return
|
||||||
}
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const url = `${proto}//${location.host}/api/browser-sessions/${sessionId.value}/ws?token=${token}`
|
||||||
|
ws = new WebSocket(url)
|
||||||
|
ws.binaryType = 'arraybuffer'
|
||||||
|
|
||||||
function refreshScreenshot() {
|
ws.onopen = () => { wsConnected.value = true }
|
||||||
screenshotUrl.value = buildScreenshotUrl()
|
|
||||||
}
|
|
||||||
|
|
||||||
let screenshotLoading = false
|
ws.onmessage = (evt) => {
|
||||||
function onScreenshotLoaded() {
|
if (evt.data instanceof ArrayBuffer) {
|
||||||
screenshotLoading = false
|
// Binary JPEG frame
|
||||||
// Trigger next refresh after a short interval if still in browser view
|
const blob = new Blob([evt.data], { type: 'image/jpeg' })
|
||||||
if (sessionId.value && !extracted.value) {
|
frameUrl.value = URL.createObjectURL(blob)
|
||||||
setTimeout(refreshScreenshot, 500)
|
// Revoke previous URL after a tick to free memory
|
||||||
}
|
setTimeout(() => { /* old URL auto-revoked */ }, 100)
|
||||||
}
|
} else {
|
||||||
function onScreenshotError() {
|
// JSON message (init, error, etc.)
|
||||||
screenshotLoading = false
|
|
||||||
if (sessionId.value && !extracted.value) {
|
|
||||||
setTimeout(refreshScreenshot, 1500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ——— Event forwarding ———
|
|
||||||
|
|
||||||
async function sendEvent(type: string, extra: Record<string, any> = {}) {
|
|
||||||
if (!sessionId.value) return
|
|
||||||
try {
|
try {
|
||||||
const eventData: any = { type, ...extra }
|
const msg = JSON.parse(evt.data)
|
||||||
await browserSessionsApi.event(sessionId.value, eventData)
|
if (msg.error) {
|
||||||
refreshScreenshot()
|
console.warn('WS error:', msg.error)
|
||||||
} catch (e) {
|
}
|
||||||
console.error('event failed', e)
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scalePointer(e: PointerEvent) {
|
ws.onclose = () => {
|
||||||
const el = browserFrameRef.value
|
wsConnected.value = false
|
||||||
|
ws = null
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
wsConnected.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsSend(data: Record<string, any>) {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFrameLoad(e: Event) {
|
||||||
|
const img = e.target as HTMLImageElement
|
||||||
|
frameW = img.naturalWidth || 1
|
||||||
|
frameH = img.naturalHeight || 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— Event forwarding (via WS) ———
|
||||||
|
|
||||||
|
function scalePoint(e: PointerEvent): { x: number; y: number } {
|
||||||
|
const el = frameRef.value
|
||||||
if (!el) return { x: e.clientX, y: e.clientY }
|
if (!el) return { x: e.clientX, y: e.clientY }
|
||||||
const rect = el.getBoundingClientRect()
|
const rect = el.getBoundingClientRect()
|
||||||
// Scale from display size to natural image size
|
|
||||||
const scaleX = imgNaturalSize.value.w / rect.width
|
|
||||||
const scaleY = imgNaturalSize.value.h / rect.height
|
|
||||||
return {
|
return {
|
||||||
x: (e.clientX - rect.left) * scaleX,
|
x: ((e.clientX - rect.left) / rect.width) * frameW,
|
||||||
y: (e.clientY - rect.top) * scaleY,
|
y: ((e.clientY - rect.top) / rect.height) * frameH,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerDown(e: PointerEvent) {
|
function onPointerDown(e: PointerEvent) {
|
||||||
const p = scalePointer(e)
|
pointerDown = true
|
||||||
pointerPos.value = p
|
const p = scalePoint(e)
|
||||||
sendEvent('click', { x: p.x, y: p.y, button: e.button === 2 ? 'right' : 'left' })
|
wsSend({ type: e.buttons === 2 ? 'mousedown' : 'mousedown', x: p.x, y: p.y, button: e.button === 2 ? 'right' : 'left' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerMove(e: PointerEvent) {
|
function onPointerMove(e: PointerEvent) {
|
||||||
const p = scalePointer(e)
|
if (!pointerDown) return
|
||||||
pointerPos.value = p
|
const p = scalePoint(e)
|
||||||
|
wsSend({ type: 'mousemove', x: p.x, y: p.y })
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerUp(e: PointerEvent) {
|
function onPointerUp(e: PointerEvent) {
|
||||||
pointerPos.value = scalePointer(e)
|
if (!pointerDown) return
|
||||||
|
pointerDown = false
|
||||||
|
const p = scalePoint(e)
|
||||||
|
wsSend({ type: 'mouseup', x: p.x, y: p.y, button: e.button === 2 ? 'right' : 'left' })
|
||||||
}
|
}
|
||||||
function onPointerCancel() { /* no-op */ }
|
|
||||||
function onDblClick(e: MouseEvent) {
|
function onDblClick(e: MouseEvent) {
|
||||||
const p = scalePointer(e as unknown as PointerEvent)
|
const p = scalePoint(e as unknown as PointerEvent)
|
||||||
sendEvent('dblclick', { x: p.x, y: p.y })
|
wsSend({ type: 'dblclick', x: p.x, y: p.y })
|
||||||
}
|
}
|
||||||
|
|
||||||
function onWheel(e: WheelEvent) {
|
function onWheel(e: WheelEvent) {
|
||||||
sendEvent('scroll', { delta_x: e.deltaX, delta_y: e.deltaY, x: pointerPos.value.x, y: pointerPos.value.y })
|
const p = scalePoint(e as unknown as PointerEvent)
|
||||||
|
wsSend({ type: 'scroll', delta_x: e.deltaX, delta_y: e.deltaY, x: p.x, y: p.y })
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Enter') {
|
const keyMap: Record<string, string> = {
|
||||||
sendEvent('key', { key: 'Enter' })
|
Enter: 'Enter', Backspace: 'Backspace', Escape: 'Escape', Tab: 'Tab',
|
||||||
} else if (e.key === 'Backspace') {
|
ArrowUp: 'ArrowUp', ArrowDown: 'ArrowDown', ArrowLeft: 'ArrowLeft', ArrowRight: 'ArrowRight',
|
||||||
sendEvent('key', { key: 'Backspace' })
|
}
|
||||||
} else if (e.key === 'Escape') {
|
if (keyMap[e.key]) {
|
||||||
sendEvent('key', { key: 'Escape' })
|
wsSend({ type: 'key', key: keyMap[e.key] })
|
||||||
} else if (e.key === 'Tab') {
|
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
||||||
sendEvent('key', { key: 'Tab' })
|
wsSend({ type: 'type', text: e.key })
|
||||||
} else if (e.key.length === 1) {
|
|
||||||
sendEvent('type', { text: e.key })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,10 +337,10 @@ function resetExtract() {
|
|||||||
extracted.value = false
|
extracted.value = false
|
||||||
result.value = null
|
result.value = null
|
||||||
selectedIndex.value = -1
|
selectedIndex.value = -1
|
||||||
refreshScreenshot()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleClose() {
|
async function handleClose() {
|
||||||
|
disconnectWs()
|
||||||
if (sessionId.value) {
|
if (sessionId.value) {
|
||||||
try { await authCaptureApi.closeSession(sessionId.value) } catch { /* ignore */ }
|
try { await authCaptureApi.closeSession(sessionId.value) } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
@@ -356,168 +352,104 @@ function closeDialog() {
|
|||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function disconnectWs() {
|
||||||
|
if (ws) {
|
||||||
|
ws.onclose = null
|
||||||
|
ws.onmessage = null
|
||||||
|
ws.close()
|
||||||
|
ws = null
|
||||||
|
}
|
||||||
|
wsConnected.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
disconnectWs()
|
||||||
|
if (sessionId.value) {
|
||||||
|
authCaptureApi.closeSession(sessionId.value).catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ——— Helpers ———
|
||||||
|
|
||||||
|
function badgeLabel(type: string): string {
|
||||||
|
return { bearer_token: 'Bearer', cookie: 'Cookie', api_key: 'API Key', credential: 'Key' }[type] || type
|
||||||
|
}
|
||||||
|
function confClass(s: number): string {
|
||||||
|
return s >= 80 ? 'conf-high' : s >= 50 ? 'conf-mid' : 'conf-low'
|
||||||
|
}
|
||||||
function maskValue(v: string): string {
|
function maskValue(v: string): string {
|
||||||
if (!v || v.length <= 8) return '***'
|
if (!v || v.length <= 8) return '***'
|
||||||
if (v.length <= 16) return v.slice(0, 4) + '…' + v.slice(-4)
|
if (v.length <= 16) return v.slice(0, 4) + '…' + v.slice(-4)
|
||||||
return v.slice(0, 8) + '…' + v.slice(-6)
|
return v.slice(0, 8) + '…' + v.slice(-6)
|
||||||
}
|
}
|
||||||
|
|
||||||
function confidenceClass(score: number): string {
|
|
||||||
if (score >= 80) return 'conf-high'
|
|
||||||
if (score >= 50) return 'conf-mid'
|
|
||||||
return 'conf-low'
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.auth-capture-body {
|
.auth-capture-body { min-height: 350px; }
|
||||||
min-height: 350px;
|
.capture-step { padding: 4px 0; }
|
||||||
}
|
|
||||||
.capture-step {
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
.capture-step-header {
|
.capture-step-header {
|
||||||
display: flex;
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
justify-content: space-between;
|
margin-bottom: 6px; flex-wrap: wrap; gap: 8px;
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.capture-step-header h4 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.capture-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.capture-hint {
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
margin: 0 0 10px;
|
|
||||||
}
|
}
|
||||||
|
.capture-step-header h4 { margin: 0; }
|
||||||
|
.capture-actions { display: flex; gap: 6px; align-items: center; }
|
||||||
|
.capture-hint { color: var(--el-text-color-secondary); font-size: 0.85rem; margin: 0 0 8px; }
|
||||||
.capture-extra-fields {
|
.capture-extra-fields {
|
||||||
margin-top: 8px;
|
margin-top: 8px; padding: 8px; background: var(--el-fill-color-lighter); border-radius: 6px;
|
||||||
padding: 8px;
|
|
||||||
background: var(--el-fill-color-lighter);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
}
|
||||||
.capture-launch-row {
|
.capture-launch-row {
|
||||||
display: flex;
|
display: flex; justify-content: space-between; align-items: center; margin-top: 4px;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
}
|
||||||
|
.ws-status { display: flex; align-items: center; gap: 4px; font-size: 0.78rem; color: var(--el-text-color-secondary); }
|
||||||
|
.ws-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
|
||||||
|
.ws-dot.connected { background: #52c41a; }
|
||||||
|
.ws-dot.disconnected { background: #ff4d4f; }
|
||||||
.browser-viewport {
|
.browser-viewport {
|
||||||
border: 1px solid var(--el-border-color);
|
border: 1px solid var(--el-border-color); border-radius: 8px; overflow: hidden;
|
||||||
border-radius: 8px;
|
background: #000; cursor: crosshair; outline: none; position: relative; min-height: 300px;
|
||||||
overflow: hidden;
|
|
||||||
background: #000;
|
|
||||||
cursor: crosshair;
|
|
||||||
outline: none;
|
|
||||||
position: relative;
|
|
||||||
min-height: 300px;
|
|
||||||
}
|
}
|
||||||
.browser-frame {
|
.browser-frame {
|
||||||
display: block;
|
display: block; width: 100%; height: auto;
|
||||||
width: 100%;
|
user-select: none; -webkit-user-drag: none;
|
||||||
height: auto;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-drag: none;
|
|
||||||
}
|
}
|
||||||
.browser-loading {
|
.browser-loading {
|
||||||
display: flex;
|
display: flex; flex-direction: column; align-items: center;
|
||||||
flex-direction: column;
|
justify-content: center; min-height: 300px;
|
||||||
align-items: center;
|
color: var(--el-text-color-secondary); gap: 12px;
|
||||||
justify-content: center;
|
|
||||||
min-height: 300px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
gap: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Candidate panel */
|
|
||||||
.candidate-panel {
|
.candidate-panel {
|
||||||
margin-top: 12px;
|
margin-top: 12px; border: 1px solid var(--el-border-color); border-radius: 8px; overflow: hidden;
|
||||||
border: 1px solid var(--el-border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.candidate-panel-header {
|
.candidate-panel-header {
|
||||||
display: flex;
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
justify-content: space-between;
|
padding: 8px 12px; background: var(--el-fill-color-light); font-size: 0.85rem; font-weight: 600;
|
||||||
align-items: center;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--el-fill-color-light);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.candidate-empty {
|
|
||||||
padding: 24px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.candidate-list {
|
|
||||||
max-height: 280px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
.candidate-empty { padding: 24px; text-align: center; color: var(--el-text-color-secondary); font-size: 0.85rem; }
|
||||||
|
.candidate-list { max-height: 280px; overflow-y: auto; }
|
||||||
.candidate-card {
|
.candidate-card {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px; cursor: pointer; border-bottom: 1px solid var(--el-border-color-light);
|
||||||
cursor: pointer;
|
|
||||||
border-bottom: 1px solid var(--el-border-color-light);
|
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
.candidate-card:last-child {
|
.candidate-card:last-child { border-bottom: none; }
|
||||||
border-bottom: none;
|
.candidate-card:hover, .candidate-card.selected { background: var(--el-color-primary-light-9); }
|
||||||
}
|
.candidate-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2px; }
|
||||||
.candidate-card:hover,
|
|
||||||
.candidate-card.selected {
|
|
||||||
background: var(--el-color-primary-light-9);
|
|
||||||
}
|
|
||||||
.candidate-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
.candidate-badge {
|
.candidate-badge {
|
||||||
display: inline-block;
|
display: inline-block; padding: 0 6px; border-radius: 4px;
|
||||||
padding: 0 6px;
|
font-size: 0.72rem; font-weight: 600; margin-right: 6px;
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-right: 6px;
|
|
||||||
}
|
}
|
||||||
.candidate-badge.bearer_token { background: #e6f7ff; color: #1890ff; }
|
.candidate-badge.bearer_token { background: #e6f7ff; color: #1890ff; }
|
||||||
.candidate-badge.cookie { background: #fff7e6; color: #d48806; }
|
.candidate-badge.cookie { background: #fff7e6; color: #d48806; }
|
||||||
|
.candidate-badge.api_key { background: #f0f5ff; color: #2f54eb; }
|
||||||
.candidate-badge.credential { background: #f6ffed; color: #52c41a; }
|
.candidate-badge.credential { background: #f6ffed; color: #52c41a; }
|
||||||
.candidate-label {
|
.candidate-label { font-size: 0.82rem; color: var(--el-text-color-secondary); }
|
||||||
font-size: 0.82rem;
|
.candidate-confidence { font-size: 0.75rem; font-weight: 600; padding: 1px 6px; border-radius: 8px; }
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
.candidate-confidence {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
.conf-high { background: #f6ffed; color: #52c41a; }
|
.conf-high { background: #f6ffed; color: #52c41a; }
|
||||||
.conf-mid { background: #fff7e6; color: #d48806; }
|
.conf-mid { background: #fff7e6; color: #d48806; }
|
||||||
.conf-low { background: #fff2f0; color: #ff4d4f; }
|
.conf-low { background: #fff2f0; color: #ff4d4f; }
|
||||||
.candidate-preview {
|
.candidate-preview { margin-left: 24px; }
|
||||||
margin-left: 24px;
|
.candidate-preview code { font-size: 0.78rem; color: var(--el-text-color-regular); word-break: break-all; }
|
||||||
}
|
|
||||||
.candidate-preview code {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--el-text-color-regular);
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.candidate-actions {
|
.candidate-actions {
|
||||||
display: flex;
|
display: flex; justify-content: flex-end; gap: 8px;
|
||||||
justify-content: flex-end;
|
padding: 8px 12px; background: var(--el-fill-color-light);
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--el-fill-color-light);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -460,13 +460,30 @@ function handleAuthCaptureSelect(candidate: { type: string; value: string; cooki
|
|||||||
if (candidate.type === 'bearer_token') {
|
if (candidate.type === 'bearer_token') {
|
||||||
form.value.auth_type = 'bearer'
|
form.value.auth_type = 'bearer'
|
||||||
form.value.auth_config.token = candidate.value
|
form.value.auth_config.token = candidate.value
|
||||||
|
ElMessage.success('已填入 Bearer Token')
|
||||||
} else if (candidate.type === 'cookie') {
|
} else if (candidate.type === 'cookie') {
|
||||||
form.value.auth_type = 'cookie'
|
form.value.auth_type = 'cookie'
|
||||||
form.value.auth_config.cookie_string = candidate.cookie_name && candidate.cookie_value
|
form.value.auth_config.cookie_string = candidate.cookie_name && candidate.cookie_value
|
||||||
? `${candidate.cookie_name}=${candidate.cookie_value}`
|
? `${candidate.cookie_name}=${candidate.cookie_value}`
|
||||||
: candidate.value
|
: candidate.value
|
||||||
|
ElMessage.success('已填入 Cookie')
|
||||||
|
} else if (candidate.type === 'api_key') {
|
||||||
|
form.value.auth_type = 'api_key'
|
||||||
|
form.value.auth_config.key = candidate.value
|
||||||
|
form.value.auth_config.header = 'X-API-Key'
|
||||||
|
ElMessage.success('已填入 API Key')
|
||||||
|
} else if (candidate.type === 'credential') {
|
||||||
|
// Try to guess — if value starts with 'sk-', treat as bearer
|
||||||
|
if (candidate.value.startsWith('sk-')) {
|
||||||
|
form.value.auth_type = 'bearer'
|
||||||
|
form.value.auth_config.token = candidate.value
|
||||||
|
ElMessage.success('已填入 Bearer Token (sk-key)')
|
||||||
|
} else {
|
||||||
|
form.value.auth_type = 'bearer'
|
||||||
|
form.value.auth_config.token = candidate.value
|
||||||
|
ElMessage.success('已填入认证信息')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ElMessage.success(`已填入${candidate.type === 'cookie' ? 'Cookie' : 'Bearer Token'}认证信息`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const quickPlatform = ref('sub2api')
|
const quickPlatform = ref('sub2api')
|
||||||
|
|||||||
Reference in New Issue
Block a user