From 6ec40857c21bcd7023af0e958a7ae96bccad204c Mon Sep 17 00:00:00 2001 From: v0 Date: Wed, 4 Mar 2026 16:57:20 +0000 Subject: [PATCH] feat: add new components and backend updates for full Scrapling functionality Extend types and create new components: BodyEditor, CookiesEditor, AdvancedOptions, BulkScrapePanel, CurlImportModal, ScrapeHistory, ExportMenu, ProxyManager. Update App.tsx, OptionsPanel, ResponsePanel. Extend backend with new API endpoints and fields. Co-authored-by: Marty Reed <317507+martyr280@users.noreply.github.com> --- backend/main.py | 280 ++++++++++- frontend/src/App.tsx | 532 ++++++++++++++------ frontend/src/components/AdvancedOptions.tsx | 204 ++++++++ frontend/src/components/BodyEditor.tsx | 131 +++++ frontend/src/components/BulkScrapePanel.tsx | 302 +++++++++++ frontend/src/components/CookiesEditor.tsx | 152 ++++++ frontend/src/components/CurlImportModal.tsx | 305 +++++++++++ frontend/src/components/ExportMenu.tsx | 136 +++++ frontend/src/components/OptionsPanel.tsx | 30 +- frontend/src/components/ProxyManager.tsx | 228 +++++++++ frontend/src/components/ResponsePanel.tsx | 6 +- frontend/src/components/ScrapeHistory.tsx | 123 +++++ frontend/src/types.ts | 86 ++++ 13 files changed, 2358 insertions(+), 157 deletions(-) create mode 100644 frontend/src/components/AdvancedOptions.tsx create mode 100644 frontend/src/components/BodyEditor.tsx create mode 100644 frontend/src/components/BulkScrapePanel.tsx create mode 100644 frontend/src/components/CookiesEditor.tsx create mode 100644 frontend/src/components/CurlImportModal.tsx create mode 100644 frontend/src/components/ExportMenu.tsx create mode 100644 frontend/src/components/ProxyManager.tsx create mode 100644 frontend/src/components/ScrapeHistory.tsx diff --git a/backend/main.py b/backend/main.py index 3f4022aa..1cf40558 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,6 +2,7 @@ import os import time import traceback +import re # Add the project root to Python path so Scrapling can be imported sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) @@ -9,7 +10,7 @@ import fastapi import fastapi.middleware.cors from pydantic import BaseModel -from typing import Optional +from typing import Optional, List, Literal app = fastapi.FastAPI() @@ -34,6 +35,9 @@ class ScrapeRequest(BaseModel): proxy: Optional[str] = None headers: Optional[dict] = None cookies: Optional[dict] = None + # Body data + body: Optional[str] = None + body_type: Optional[str] = None # json, form, raw # Fetcher-specific retries: Optional[int] = 3 retry_delay: Optional[int] = 1 @@ -47,12 +51,38 @@ class ScrapeRequest(BaseModel): wait: Optional[int] = None disable_resources: bool = False wait_selector: Optional[str] = None + wait_selector_state: Optional[str] = "visible" # visible, hidden, attached, detached google_search: bool = True # StealthyFetcher-specific solve_cloudflare: bool = False hide_canvas: bool = False block_webrtc: bool = False allow_webgl: bool = False + # Advanced options + verify_ssl: bool = True + locale: Optional[str] = None + useragent: Optional[str] = None + real_chrome: bool = False + cdp_url: Optional[str] = None + timezone_id: Optional[str] = None + max_redirects: Optional[int] = 10 + + +class BulkScrapeRequest(BaseModel): + urls: List[str] + fetcher_type: str = "fetcher" + method: str = "get" + extraction_type: str = "markdown" + css_selector: Optional[str] = None + main_content_only: bool = True + timeout: Optional[int] = 30 + proxy: Optional[str] = None + headers: Optional[dict] = None + cookies: Optional[dict] = None + + +class ParseCurlRequest(BaseModel): + curl_command: str @app.get("/api/health") @@ -86,6 +116,19 @@ async def scrape(req: ScrapeRequest): method_kwargs["headers"] = req.headers if req.cookies: method_kwargs["cookies"] = req.cookies + if not req.verify_ssl: + method_kwargs["verify"] = False + if req.useragent: + method_kwargs["useragent"] = req.useragent + if req.max_redirects: + method_kwargs["max_redirects"] = req.max_redirects + + # Handle body data for POST/PUT + if req.body and req.method.lower() in ("post", "put"): + if req.body_type == "json": + method_kwargs["json"] = req.body + else: + method_kwargs["data"] = req.body method_fn = getattr(Fetcher, req.method.lower(), Fetcher.get) response = method_fn(req.url, **method_kwargs) @@ -104,6 +147,14 @@ async def scrape(req: ScrapeRequest): fetch_kwargs["wait"] = req.wait if req.wait_selector: fetch_kwargs["wait_selector"] = req.wait_selector + if req.wait_selector_state: + fetch_kwargs["wait_selector_state"] = req.wait_selector_state + if req.locale: + fetch_kwargs["locale"] = req.locale + if req.timezone_id: + fetch_kwargs["timezone_id"] = req.timezone_id + if req.useragent: + fetch_kwargs["useragent"] = req.useragent response = DynamicFetcher.fetch(req.url, **fetch_kwargs) @@ -124,8 +175,20 @@ async def scrape(req: ScrapeRequest): fetch_kwargs["wait"] = req.wait if req.wait_selector: fetch_kwargs["wait_selector"] = req.wait_selector + if req.wait_selector_state: + fetch_kwargs["wait_selector_state"] = req.wait_selector_state if req.solve_cloudflare: fetch_kwargs["solve_cloudflare"] = True + if req.locale: + fetch_kwargs["locale"] = req.locale + if req.timezone_id: + fetch_kwargs["timezone_id"] = req.timezone_id + if req.useragent: + fetch_kwargs["useragent"] = req.useragent + if req.real_chrome: + fetch_kwargs["real_chrome"] = True + if req.cdp_url: + fetch_kwargs["cdp_url"] = req.cdp_url response = StealthyFetcher.fetch(req.url, **fetch_kwargs) @@ -183,3 +246,218 @@ async def scrape(req: ScrapeRequest): "elapsed": elapsed, }, ) + + +@app.post("/api/bulk-scrape") +async def bulk_scrape(req: BulkScrapeRequest): + """ + Scrape multiple URLs sequentially. + Returns a list of results for each URL. + """ + results = [] + + for url in req.urls: + start_time = time.time() + + try: + from scrapling.fetchers import Fetcher, DynamicFetcher, StealthyFetcher + from scrapling.core.shell import Convertor + + response = None + + if req.fetcher_type == "fetcher": + method_kwargs = { + "timeout": req.timeout, + } + if req.proxy: + method_kwargs["proxy"] = req.proxy + if req.headers: + method_kwargs["headers"] = req.headers + if req.cookies: + method_kwargs["cookies"] = req.cookies + + method_fn = getattr(Fetcher, req.method.lower(), Fetcher.get) + response = method_fn(url, **method_kwargs) + + elif req.fetcher_type == "dynamic": + fetch_kwargs = { + "timeout": req.timeout, + } + if req.proxy: + fetch_kwargs["proxy"] = {"server": req.proxy} + + response = DynamicFetcher.fetch(url, **fetch_kwargs) + + elif req.fetcher_type == "stealthy": + fetch_kwargs = { + "timeout": req.timeout, + } + if req.proxy: + fetch_kwargs["proxy"] = {"server": req.proxy} + + response = StealthyFetcher.fetch(url, **fetch_kwargs) + + else: + results.append({ + "url": url, + "status": "error", + "error": f"Unknown fetcher type: {req.fetcher_type}", + }) + continue + + # Extract content + content_parts = list( + Convertor._extract_content( + response, + extraction_type=req.extraction_type, + css_selector=req.css_selector, + main_content_only=req.main_content_only, + ) + ) + content = "".join(content_parts) + + elapsed = round(time.time() - start_time, 3) + + # Serialize cookies + cookies_data = {} + if response.cookies: + if isinstance(response.cookies, dict): + cookies_data = response.cookies + elif isinstance(response.cookies, (list, tuple)): + for c in response.cookies: + if isinstance(c, dict): + cookies_data.update(c) + + # Serialize headers + headers_data = {} + if response.headers: + headers_data = dict(response.headers) if not isinstance(response.headers, dict) else response.headers + + results.append({ + "url": url, + "status": "success", + "response": { + "status": response.status, + "reason": response.reason, + "url": response.url, + "content": content, + "headers": headers_data, + "cookies": cookies_data, + "elapsed": elapsed, + } + }) + + except Exception as e: + elapsed = round(time.time() - start_time, 3) + results.append({ + "url": url, + "status": "error", + "error": str(e), + "elapsed": elapsed, + }) + + return {"results": results} + + +@app.post("/api/parse-curl") +async def parse_curl(req: ParseCurlRequest): + """ + Parse a curl command and extract URL, method, headers, cookies, and body. + This is a simple parser - the frontend has a more complete one. + """ + try: + curl_str = req.curl_command.strip() + + if not curl_str.lower().startswith('curl'): + return fastapi.responses.JSONResponse( + status_code=400, + content={"error": "Command must start with 'curl'"}, + ) + + result = { + "url": "", + "method": "get", + "headers": {}, + "cookies": {}, + "body": "", + "body_type": "none", + } + + # Normalize the command (remove line continuations) + normalized = re.sub(r'\\\r?\n', ' ', curl_str) + normalized = re.sub(r'\s+', ' ', normalized) + + # Extract URL + url_match = re.search(r'curl\s+(?:-[^\s]+\s+)*[\'"]?(https?://[^\s\'"]+)[\'"]?', normalized, re.IGNORECASE) + if url_match: + result["url"] = url_match.group(1).strip('"\'') + + # Extract method + method_match = re.search(r'-X\s+[\'"]?(\w+)[\'"]?', normalized, re.IGNORECASE) + if not method_match: + method_match = re.search(r'--request\s+[\'"]?(\w+)[\'"]?', normalized, re.IGNORECASE) + if method_match: + result["method"] = method_match.group(1).lower() + + # Extract headers + header_pattern = re.compile(r'(?:-H|--header)\s+[\'"]([^\'"]+)[\'"]', re.IGNORECASE) + for match in header_pattern.finditer(normalized): + header_line = match.group(1) + colon_idx = header_line.find(':') + if colon_idx != -1: + key = header_line[:colon_idx].strip() + value = header_line[colon_idx + 1:].strip() + + if key.lower() == 'cookie': + # Parse cookie header + for cookie_pair in value.split(';'): + eq_idx = cookie_pair.find('=') + if eq_idx != -1: + c_key = cookie_pair[:eq_idx].strip() + c_value = cookie_pair[eq_idx + 1:].strip() + result["cookies"][c_key] = c_value + else: + result["headers"][key] = value + + # Extract body data + data_match = re.search(r'(?:-d|--data(?:-raw|-binary)?)\s+[\'"]([^\'"]+)[\'"]', normalized, re.IGNORECASE) + if not data_match: + data_match = re.search(r'(?:-d|--data(?:-raw|-binary)?)\s+([^\s]+)', normalized, re.IGNORECASE) + + if data_match: + result["body"] = data_match.group(1) + + if result["body"].startswith('{') or result["body"].startswith('['): + result["body_type"] = "json" + elif '=' in result["body"]: + result["body_type"] = "form" + else: + result["body_type"] = "raw" + + # If there's body data and method is GET, assume POST + if result["method"] == "get": + result["method"] = "post" + + # Extract cookies from -b or --cookie + cookie_match = re.search(r'(?:-b|--cookie)\s+[\'"]([^\'"]+)[\'"]', normalized, re.IGNORECASE) + if cookie_match: + for cookie_pair in cookie_match.group(1).split(';'): + eq_idx = cookie_pair.find('=') + if eq_idx != -1: + c_key = cookie_pair[:eq_idx].strip() + c_value = cookie_pair[eq_idx + 1:].strip() + result["cookies"][c_key] = c_value + + if not result["url"]: + return fastapi.responses.JSONResponse( + status_code=400, + content={"error": "Could not extract URL from curl command"}, + ) + + return result + + except Exception as e: + return fastapi.responses.JSONResponse( + status_code=500, + content={"error": str(e), "traceback": traceback.format_exc()}, + ) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1099f862..26af3286 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,13 +1,32 @@ -import { useState, useCallback } from 'react' -import { Send, RotateCcw, FileCode, Hash, SlidersHorizontal, Braces } from 'lucide-react' +import { useState, useCallback, useEffect } from 'react' +import { Send, RotateCcw, FileCode, SlidersHorizontal, Braces, FileText, Terminal, History, Shield, Cookie, Layers } from 'lucide-react' import FetcherSelector from './components/FetcherSelector' import HeaderEditor from './components/HeaderEditor' import OptionsPanel from './components/OptionsPanel' import ResponsePanel from './components/ResponsePanel' -import type { ScrapeRequest, ScrapeResponse, ScrapeError, HeaderPair, HttpMethod, ExtractionType, FetcherType } from './types' -import { DEFAULT_REQUEST } from './types' +import BulkScrapePanel from './components/BulkScrapePanel' +import CurlImportModal from './components/CurlImportModal' +import BodyEditor from './components/BodyEditor' +import CookiesEditor from './components/CookiesEditor' +import ScrapeHistory from './components/ScrapeHistory' +import ProxyManager from './components/ProxyManager' +import type { + ScrapeRequest, + ScrapeResponse, + ScrapeError, + HeaderPair, + HttpMethod, + ExtractionType, + FetcherType, + ParsedCurl, + ScrapeHistoryItem, + ProxyConfig, + BodyType, +} from './types' +import { DEFAULT_REQUEST, DEFAULT_PROXY_CONFIG } from './types' -type ConfigTab = 'basic' | 'headers' | 'options' +type MainMode = 'single' | 'bulk' +type ConfigTab = 'basic' | 'headers' | 'cookies' | 'body' | 'options' | 'proxies' const HTTP_METHODS: { value: HttpMethod; label: string; color: string }[] = [ { value: 'get', label: 'GET', color: 'text-status-success' }, @@ -16,13 +35,44 @@ const HTTP_METHODS: { value: HttpMethod; label: string; color: string }[] = [ { value: 'delete', label: 'DELETE', color: 'text-status-error' }, ] +const HISTORY_STORAGE_KEY = 'scrapling_history' +const MAX_HISTORY_ITEMS = 20 + +function loadHistory(): ScrapeHistoryItem[] { + try { + const stored = localStorage.getItem(HISTORY_STORAGE_KEY) + return stored ? JSON.parse(stored) : [] + } catch { + return [] + } +} + +function saveHistory(history: ScrapeHistoryItem[]) { + try { + localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(history.slice(0, MAX_HISTORY_ITEMS))) + } catch { + // localStorage might be full or disabled + } +} + export default function App() { + const [mainMode, setMainMode] = useState('single') const [request, setRequest] = useState({ ...DEFAULT_REQUEST }) const [response, setResponse] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const [configTab, setConfigTab] = useState('basic') const [headerPairs, setHeaderPairs] = useState([]) + const [cookiePairs, setCookiePairs] = useState([]) + const [showCurlModal, setShowCurlModal] = useState(false) + const [history, setHistory] = useState(loadHistory) + const [showHistory, setShowHistory] = useState(false) + const [proxyConfig, setProxyConfig] = useState({ ...DEFAULT_PROXY_CONFIG }) + + // Save history to localStorage whenever it changes + useEffect(() => { + saveHistory(history) + }, [history]) const updateRequest = useCallback((updates: Partial) => { setRequest((prev) => ({ ...prev, ...updates })) @@ -37,6 +87,7 @@ export default function App() { setResponse(null) setError(null) setHeaderPairs([]) + setCookiePairs([]) }, []) const handleScrape = useCallback(async () => { @@ -52,13 +103,31 @@ export default function App() { if (h.key.trim()) headersObj[h.key.trim()] = h.value } + // Build cookies from pairs + const cookiesObj: Record = {} + for (const c of cookiePairs) { + if (c.key.trim()) cookiesObj[c.key.trim()] = c.value + } + + // Get proxy from rotation if enabled + let proxy = request.proxy.trim() || undefined + if (proxyConfig.enabled && proxyConfig.proxies.length > 0) { + // Simple round-robin for now + const proxyIndex = Math.floor(Math.random() * proxyConfig.proxies.length) + const selectedProxy = proxyConfig.proxies[proxyIndex] + proxy = `${proxyConfig.protocol}://${selectedProxy}` + } + const payload = { ...request, headers: Object.keys(headersObj).length > 0 ? headersObj : undefined, + cookies: Object.keys(cookiesObj).length > 0 ? cookiesObj : undefined, css_selector: request.css_selector.trim() || undefined, - proxy: request.proxy.trim() || undefined, + proxy, wait_selector: request.wait_selector.trim() || undefined, wait: request.wait || undefined, + body: request.body_type !== 'none' ? request.body_content : undefined, + body_type: request.body_type !== 'none' ? request.body_type : undefined, } try { @@ -73,7 +142,22 @@ export default function App() { if (!res.ok) { setError(data as ScrapeError) } else { - setResponse(data as ScrapeResponse) + const responseData = data as ScrapeResponse + setResponse(responseData) + + // Add to history + const historyItem: ScrapeHistoryItem = { + id: crypto.randomUUID(), + url: request.url, + method: request.method, + fetcher_type: request.fetcher_type, + status: responseData.status, + elapsed: responseData.elapsed, + timestamp: Date.now(), + request: { ...request }, + response: responseData, + } + setHistory((prev) => [historyItem, ...prev.slice(0, MAX_HISTORY_ITEMS - 1)]) } } catch (err) { setError({ @@ -82,12 +166,57 @@ export default function App() { } finally { setLoading(false) } - }, [request, headerPairs]) + }, [request, headerPairs, cookiePairs, proxyConfig]) + + const handleCurlImport = useCallback((parsed: ParsedCurl, newHeaders: HeaderPair[], newCookies: HeaderPair[]) => { + updateRequest({ + url: parsed.url, + method: parsed.method, + body_type: parsed.body_type, + body_content: parsed.body, + }) + setHeaderPairs(newHeaders) + setCookiePairs(newCookies) + }, [updateRequest]) + + const handleHistoryRestore = useCallback((item: ScrapeHistoryItem) => { + setRequest({ ...item.request }) + setResponse(item.response) + setError(null) + setShowHistory(false) + + // Restore headers + const newHeaders: HeaderPair[] = Object.entries(item.request.headers || {}).map(([key, value]) => ({ + key, + value, + id: crypto.randomUUID(), + })) + setHeaderPairs(newHeaders) + + // Restore cookies + const newCookies: HeaderPair[] = Object.entries(item.request.cookies || {}).map(([key, value]) => ({ + key, + value, + id: crypto.randomUUID(), + })) + setCookiePairs(newCookies) + }, []) + + const handleHistoryClear = useCallback(() => { + setHistory([]) + }, []) + + const handleHistoryRemove = useCallback((id: string) => { + setHistory((prev) => prev.filter((item) => item.id !== id)) + }, []) const configTabs: { id: ConfigTab; label: string; icon: React.ReactNode }[] = [ { id: 'basic', label: 'Basic', icon: }, { id: 'headers', label: 'Headers', icon: }, + { id: 'cookies', label: 'Cookies', icon: }, + { id: 'body', label: 'Body', icon: }, { id: 'options', label: 'Options', icon: }, + { id: 'proxies', label: 'Proxies', icon: }, ] return ( @@ -103,168 +232,256 @@ export default function App() {

Web Scraping Interface

- +
+ + +
- {/* Main Content */} -
- {/* Fetcher Selector */} -
- - -
- - {/* URL Bar */} -
- {request.fetcher_type === 'fetcher' && ( - - )} -
- updateRequest({ url: e.target.value })} - onKeyDown={(e) => e.key === 'Enter' && handleScrape()} - placeholder="https://example.com" - className="w-full bg-surface-100 border border-border rounded-xl px-4 py-3 text-sm font-mono text-zinc-200 placeholder:text-zinc-600 focus:outline-none focus:border-accent/50 transition-colors" - /> -
+ {/* Mode Tabs */} +
+
+ -
- - {/* Two-column layout: Config + Response */} -
- {/* Config Panel */} -
+ + {/* Main Content */} +
+ {showHistory ? ( +
+ +
+ ) : mainMode === 'bulk' ? ( + + ) : ( + <> + {/* Fetcher Selector */} +
+ + +
+ + {/* URL Bar */} +
+ {request.fetcher_type === 'fetcher' && ( + + )} +
+ updateRequest({ url: e.target.value })} + onKeyDown={(e) => e.key === 'Enter' && handleScrape()} + placeholder="https://example.com" + className="w-full bg-surface-100 border border-border rounded-xl px-4 py-3 text-sm font-mono text-zinc-200 placeholder:text-zinc-600 focus:outline-none focus:border-accent/50 transition-colors" + /> +
+ + +
+ + {/* Two-column layout: Config + Response */} +
+ {/* Config Panel */} + + + {/* Response Panel */} +
- - - {/* Response Panel */} - - + + )}
{/* Footer */} @@ -282,6 +499,13 @@ export default function App() { {' '}v0.4.1

+ + {/* Curl Import Modal */} + setShowCurlModal(false)} + onImport={handleCurlImport} + /> ) } diff --git a/frontend/src/components/AdvancedOptions.tsx b/frontend/src/components/AdvancedOptions.tsx new file mode 100644 index 00000000..640b4f12 --- /dev/null +++ b/frontend/src/components/AdvancedOptions.tsx @@ -0,0 +1,204 @@ +import type { ScrapeRequest, WaitSelectorState } from '../types' + +interface Props { + request: ScrapeRequest + onChange: (updates: Partial) => void +} + +function Toggle({ label, checked, onChange, description }: { + label: string + checked: boolean + onChange: (v: boolean) => void + description?: string +}) { + return ( + + ) +} + +function TextInput({ label, value, onChange, placeholder, description }: { + label: string + value: string + onChange: (v: string) => void + placeholder?: string + description?: string +}) { + return ( +
+ + {description && {description}} + onChange(e.target.value)} + placeholder={placeholder} + className="bg-surface-50 border border-border rounded-lg px-3 py-1.5 text-sm font-mono text-zinc-300 placeholder:text-zinc-600 focus:outline-none focus:border-accent/50 transition-colors w-full" + /> +
+ ) +} + +function NumberInput({ label, value, onChange, min, max, description }: { + label: string + value: number | null + onChange: (v: number | null) => void + min?: number + max?: number + description?: string +}) { + return ( +
+ + {description && {description}} + onChange(e.target.value === '' ? null : Number(e.target.value))} + min={min} + max={max} + className="bg-surface-50 border border-border rounded-lg px-3 py-1.5 text-sm font-mono text-zinc-300 placeholder:text-zinc-600 focus:outline-none focus:border-accent/50 transition-colors w-full" + /> +
+ ) +} + +function SelectInput({ label, value, onChange, options, description }: { + label: string + value: T + onChange: (v: T) => void + options: { label: string; value: T }[] + description?: string +}) { + return ( +
+ + {description && {description}} + +
+ ) +} + +export default function AdvancedOptions({ request, onChange }: Props) { + const showBrowserOptions = request.fetcher_type === 'dynamic' || request.fetcher_type === 'stealthy' + + return ( +
+ Advanced Options + + {/* SSL & Security */} + onChange({ verify_ssl: v })} + description="Verify SSL certificates" + /> + + {/* Network */} + onChange({ max_redirects: v ?? 10 })} + min={0} + max={50} + description="Maximum number of redirects to follow" + /> + + {/* User Agent */} + onChange({ useragent: v })} + placeholder="Custom user agent string" + description="Override default browser user agent" + /> + + {/* Browser-specific options */} + {showBrowserOptions && ( + <> +
+ Browser Options + + + label="Wait Selector State" + value={request.wait_selector_state} + onChange={(v) => onChange({ wait_selector_state: v })} + options={[ + { label: 'Visible', value: 'visible' }, + { label: 'Hidden', value: 'hidden' }, + { label: 'Attached', value: 'attached' }, + { label: 'Detached', value: 'detached' }, + ]} + description="State to wait for when using wait selector" + /> + + onChange({ locale: v })} + placeholder="en-US" + description="Browser locale for content negotiation" + /> + + onChange({ timezone_id: v })} + placeholder="America/New_York" + description="Override browser timezone" + /> + + {request.fetcher_type === 'stealthy' && ( + <> + onChange({ real_chrome: v })} + description="Use real Chrome installation instead of bundled" + /> + + onChange({ cdp_url: v })} + placeholder="ws://localhost:9222" + description="Connect to existing Chrome DevTools Protocol" + /> + + )} + + )} +
+ ) +} diff --git a/frontend/src/components/BodyEditor.tsx b/frontend/src/components/BodyEditor.tsx new file mode 100644 index 00000000..c65cc87e --- /dev/null +++ b/frontend/src/components/BodyEditor.tsx @@ -0,0 +1,131 @@ +import { useState, useEffect } from 'react' +import { AlertCircle, Check } from 'lucide-react' +import type { BodyType } from '../types' + +interface Props { + bodyType: BodyType + bodyContent: string + onTypeChange: (type: BodyType) => void + onContentChange: (content: string) => void +} + +const BODY_TYPES: { value: BodyType; label: string }[] = [ + { value: 'none', label: 'None' }, + { value: 'json', label: 'JSON' }, + { value: 'form', label: 'Form' }, + { value: 'raw', label: 'Raw' }, +] + +function isValidJson(str: string): boolean { + if (!str.trim()) return true + try { + JSON.parse(str) + return true + } catch { + return false + } +} + +export default function BodyEditor({ bodyType, bodyContent, onTypeChange, onContentChange }: Props) { + const [jsonValid, setJsonValid] = useState(true) + + useEffect(() => { + if (bodyType === 'json') { + setJsonValid(isValidJson(bodyContent)) + } else { + setJsonValid(true) + } + }, [bodyType, bodyContent]) + + const getPlaceholder = () => { + switch (bodyType) { + case 'json': + return '{\n "key": "value"\n}' + case 'form': + return 'key1=value1&key2=value2' + case 'raw': + return 'Raw body content...' + default: + return '' + } + } + + const formatJson = () => { + if (bodyType === 'json' && bodyContent.trim()) { + try { + const parsed = JSON.parse(bodyContent) + onContentChange(JSON.stringify(parsed, null, 2)) + } catch { + // Invalid JSON, do nothing + } + } + } + + return ( +
+
+ Request Body + {bodyType === 'json' && ( +
+ {bodyContent.trim() && ( + + {jsonValid ? : } + {jsonValid ? 'Valid' : 'Invalid'} + + )} + +
+ )} +
+ + {/* Body type selector */} +
+ {BODY_TYPES.map((type) => ( + + ))} +
+ + {/* Body content editor */} + {bodyType !== 'none' && ( +