@doppelgangerdev/doppelganger 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +9 -0
- package/.github/workflows/docker-publish.yml +59 -0
- package/CODE_OF_CONDUCT.md +28 -0
- package/CONTRIBUTING.md +42 -0
- package/Dockerfile +44 -0
- package/LICENSE +163 -0
- package/README.md +133 -0
- package/TERMS.md +16 -0
- package/THIRD_PARTY_LICENSES.md +3502 -0
- package/agent.js +1240 -0
- package/headful.js +171 -0
- package/index.html +21 -0
- package/n8n-nodes-doppelganger/LICENSE +201 -0
- package/n8n-nodes-doppelganger/README.md +42 -0
- package/n8n-nodes-doppelganger/package-lock.json +6128 -0
- package/n8n-nodes-doppelganger/package.json +36 -0
- package/n8n-nodes-doppelganger/src/credentials/DoppelgangerApi.credentials.ts +35 -0
- package/n8n-nodes-doppelganger/src/index.ts +4 -0
- package/n8n-nodes-doppelganger/src/nodes/Doppelganger/Doppelganger.node.ts +147 -0
- package/n8n-nodes-doppelganger/src/nodes/Doppelganger/icon.png +0 -0
- package/n8n-nodes-doppelganger/tsconfig.json +14 -0
- package/package.json +45 -0
- package/postcss.config.js +6 -0
- package/public/icon.png +0 -0
- package/public/novnc.html +151 -0
- package/public/styles.css +86 -0
- package/scrape.js +389 -0
- package/server.js +875 -0
- package/src/App.tsx +722 -0
- package/src/components/AuthScreen.tsx +95 -0
- package/src/components/CodeEditor.tsx +70 -0
- package/src/components/DashboardScreen.tsx +133 -0
- package/src/components/EditorScreen.tsx +1519 -0
- package/src/components/ExecutionDetailScreen.tsx +115 -0
- package/src/components/ExecutionsScreen.tsx +156 -0
- package/src/components/LoadingScreen.tsx +26 -0
- package/src/components/NotFoundScreen.tsx +34 -0
- package/src/components/RichInput.tsx +68 -0
- package/src/components/SettingsScreen.tsx +228 -0
- package/src/components/Sidebar.tsx +61 -0
- package/src/components/app/CenterAlert.tsx +44 -0
- package/src/components/app/CenterConfirm.tsx +33 -0
- package/src/components/app/EditorLoader.tsx +89 -0
- package/src/components/editor/ActionPalette.tsx +79 -0
- package/src/components/editor/JsonEditorPane.tsx +71 -0
- package/src/components/editor/ResultsPane.tsx +641 -0
- package/src/components/editor/actionCatalog.ts +23 -0
- package/src/components/settings/AgentAiPanel.tsx +105 -0
- package/src/components/settings/ApiKeyPanel.tsx +68 -0
- package/src/components/settings/CookiesPanel.tsx +154 -0
- package/src/components/settings/LayoutPanel.tsx +46 -0
- package/src/components/settings/ScreenshotsPanel.tsx +64 -0
- package/src/components/settings/SettingsHeader.tsx +28 -0
- package/src/components/settings/StoragePanel.tsx +35 -0
- package/src/index.css +287 -0
- package/src/main.tsx +13 -0
- package/src/types.ts +114 -0
- package/src/utils/syntaxHighlight.ts +140 -0
- package/start-vnc.sh +52 -0
- package/tailwind.config.js +22 -0
- package/tsconfig.json +39 -0
- package/tsconfig.node.json +12 -0
- package/vite.config.mts +27 -0
package/src/App.tsx
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
|
3
|
+
import { User, Task, ViewMode, Results, ConfirmRequest } from './types';
|
|
4
|
+
import Sidebar from './components/Sidebar';
|
|
5
|
+
import AuthScreen from './components/AuthScreen';
|
|
6
|
+
import DashboardScreen from './components/DashboardScreen';
|
|
7
|
+
import EditorScreen from './components/EditorScreen';
|
|
8
|
+
import SettingsScreen from './components/SettingsScreen';
|
|
9
|
+
import LoadingScreen from './components/LoadingScreen';
|
|
10
|
+
import ExecutionsScreen from './components/ExecutionsScreen';
|
|
11
|
+
import ExecutionDetailScreen from './components/ExecutionDetailScreen';
|
|
12
|
+
import NotFoundScreen from './components/NotFoundScreen';
|
|
13
|
+
import CenterAlert from './components/app/CenterAlert';
|
|
14
|
+
import CenterConfirm from './components/app/CenterConfirm';
|
|
15
|
+
import EditorLoader from './components/app/EditorLoader';
|
|
16
|
+
|
|
17
|
+
export default function App() {
|
|
18
|
+
const navigate = useNavigate();
|
|
19
|
+
const location = useLocation();
|
|
20
|
+
const [, setUser] = useState<User | null>(null);
|
|
21
|
+
const [authStatus, setAuthStatus] = useState<'checking' | 'login' | 'setup' | 'authenticated'>('checking');
|
|
22
|
+
|
|
23
|
+
// Auth Screen State
|
|
24
|
+
const [authError, setAuthError] = useState('');
|
|
25
|
+
|
|
26
|
+
// Dashboard State
|
|
27
|
+
const [tasks, setTasks] = useState<Task[]>([]);
|
|
28
|
+
|
|
29
|
+
// Editor State
|
|
30
|
+
const [currentTask, setCurrentTask] = useState<Task | null>(null);
|
|
31
|
+
const [editorView, setEditorView] = useState<ViewMode>('visual');
|
|
32
|
+
const [isExecuting, setIsExecuting] = useState(false);
|
|
33
|
+
const [results, setResults] = useState<Results | null>(null);
|
|
34
|
+
const [pinnedResultsByTask, setPinnedResultsByTask] = useState<Record<string, Results>>({});
|
|
35
|
+
const [saveMsg, setSaveMsg] = useState('');
|
|
36
|
+
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
|
37
|
+
|
|
38
|
+
const [centerAlert, setCenterAlert] = useState<{ message: string; tone?: 'success' | 'error' } | null>(null);
|
|
39
|
+
const [centerConfirm, setCenterConfirm] = useState<ConfirmRequest | null>(null);
|
|
40
|
+
const confirmResolverRef = useRef<((value: boolean) => void) | null>(null);
|
|
41
|
+
const executeAbortRef = useRef<AbortController | null>(null);
|
|
42
|
+
const showAlert = (message: string, tone: 'success' | 'error' = 'success') => {
|
|
43
|
+
setCenterAlert({ message, tone });
|
|
44
|
+
if (tone === 'success') {
|
|
45
|
+
setTimeout(() => {
|
|
46
|
+
setCenterAlert((prev) => (prev && prev.message === message ? null : prev));
|
|
47
|
+
}, 2000);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const requestConfirm = (request: string | ConfirmRequest) => {
|
|
51
|
+
return new Promise<boolean>((resolve) => {
|
|
52
|
+
confirmResolverRef.current = resolve;
|
|
53
|
+
if (typeof request === 'string') {
|
|
54
|
+
setCenterConfirm({ message: request });
|
|
55
|
+
} else {
|
|
56
|
+
setCenterConfirm(request);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
const closeConfirm = (result: boolean) => {
|
|
61
|
+
const resolver = confirmResolverRef.current;
|
|
62
|
+
confirmResolverRef.current = null;
|
|
63
|
+
setCenterConfirm(null);
|
|
64
|
+
if (resolver) resolver(result);
|
|
65
|
+
};
|
|
66
|
+
const formatLabel = (value: string) => value ? value[0].toUpperCase() + value.slice(1) : value;
|
|
67
|
+
const ensureActionIds = (task: Task) => {
|
|
68
|
+
if (!task.actions || !Array.isArray(task.actions)) return task;
|
|
69
|
+
let changed = false;
|
|
70
|
+
const nextActions = task.actions.map((action, index) => {
|
|
71
|
+
if (action.id) return action;
|
|
72
|
+
changed = true;
|
|
73
|
+
return { ...action, id: `act_${Date.now()}_${index}_${Math.floor(Math.random() * 1000)}` };
|
|
74
|
+
});
|
|
75
|
+
return changed ? { ...task, actions: nextActions } : task;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const pinnedResultsKey = 'doppelganger.pinnedResults';
|
|
79
|
+
const getTaskKey = (task?: Task | null) => task?.id ? String(task.id) : 'new';
|
|
80
|
+
const currentTaskKey = getTaskKey(currentTask);
|
|
81
|
+
const pinnedResults = currentTask ? pinnedResultsByTask[currentTaskKey] || null : null;
|
|
82
|
+
|
|
83
|
+
const formatExecutionError = (rawMessage: string, mode?: string) => {
|
|
84
|
+
const message = String(rawMessage || '').trim();
|
|
85
|
+
if (!message) return 'Execution failed.';
|
|
86
|
+
|
|
87
|
+
const lower = message.toLowerCase();
|
|
88
|
+
if (mode === 'headful') {
|
|
89
|
+
if (lower.includes('missing x server') || lower.includes('$display')) {
|
|
90
|
+
return 'Headful browser could not start because no display server is available.';
|
|
91
|
+
}
|
|
92
|
+
if (lower.includes('failed to connect to the bus')) {
|
|
93
|
+
return 'Headful browser could not start due to missing system services.';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let cleaned = message;
|
|
98
|
+
const flagsIndex = cleaned.indexOf('--disable-');
|
|
99
|
+
if (flagsIndex > 0) {
|
|
100
|
+
cleaned = cleaned.slice(0, flagsIndex).trim();
|
|
101
|
+
}
|
|
102
|
+
if (cleaned.length > 240) {
|
|
103
|
+
cleaned = `${cleaned.slice(0, 240)}...`;
|
|
104
|
+
}
|
|
105
|
+
return cleaned || 'Execution failed.';
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const isDisplayUnavailable = (message: string) => {
|
|
109
|
+
const lower = String(message || '').toLowerCase();
|
|
110
|
+
return lower.includes('missing x server')
|
|
111
|
+
|| lower.includes('$display')
|
|
112
|
+
|| lower.includes('platform failed to initialize')
|
|
113
|
+
|| lower.includes('no display server');
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const makeDefaultTask = () => ({
|
|
117
|
+
name: "Imported Task",
|
|
118
|
+
url: "",
|
|
119
|
+
mode: "scrape",
|
|
120
|
+
wait: 3,
|
|
121
|
+
selector: "",
|
|
122
|
+
rotateUserAgents: false,
|
|
123
|
+
humanTyping: false,
|
|
124
|
+
stealth: {
|
|
125
|
+
allowTypos: false,
|
|
126
|
+
idleMovements: false,
|
|
127
|
+
overscroll: false,
|
|
128
|
+
deadClicks: false,
|
|
129
|
+
fatigue: false,
|
|
130
|
+
naturalTyping: false
|
|
131
|
+
},
|
|
132
|
+
actions: [],
|
|
133
|
+
variables: {},
|
|
134
|
+
includeShadowDom: true
|
|
135
|
+
} as Task);
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
checkAuth();
|
|
140
|
+
}, []);
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
try {
|
|
144
|
+
const stored = localStorage.getItem(pinnedResultsKey);
|
|
145
|
+
if (stored) {
|
|
146
|
+
const parsed = JSON.parse(stored);
|
|
147
|
+
if (parsed && typeof parsed === 'object') {
|
|
148
|
+
setPinnedResultsByTask(parsed);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// ignore
|
|
153
|
+
}
|
|
154
|
+
}, []);
|
|
155
|
+
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
try {
|
|
158
|
+
localStorage.setItem(pinnedResultsKey, JSON.stringify(pinnedResultsByTask));
|
|
159
|
+
} catch {
|
|
160
|
+
// ignore
|
|
161
|
+
}
|
|
162
|
+
}, [pinnedResultsByTask]);
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (!location.pathname.startsWith('/tasks') && editorView === 'history') {
|
|
166
|
+
setEditorView('visual');
|
|
167
|
+
}
|
|
168
|
+
}, [location.pathname, editorView]);
|
|
169
|
+
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (location.pathname === '/tasks/new' && !currentTask) {
|
|
172
|
+
const newTask = buildNewTask();
|
|
173
|
+
setCurrentTask(newTask);
|
|
174
|
+
setResults(null);
|
|
175
|
+
}
|
|
176
|
+
}, [location.pathname, currentTask]);
|
|
177
|
+
|
|
178
|
+
const checkAuth = async () => {
|
|
179
|
+
try {
|
|
180
|
+
const res = await fetch('/api/auth/me');
|
|
181
|
+
const data = await res.json();
|
|
182
|
+
if (data.authenticated) {
|
|
183
|
+
setUser(data.user);
|
|
184
|
+
setAuthStatus('authenticated');
|
|
185
|
+
loadTasks();
|
|
186
|
+
} else {
|
|
187
|
+
const sRes = await fetch('/api/auth/check-setup');
|
|
188
|
+
const sData = await sRes.json();
|
|
189
|
+
setAuthStatus(sData.setupRequired ? 'setup' : 'login');
|
|
190
|
+
}
|
|
191
|
+
} catch (e) {
|
|
192
|
+
setAuthStatus('login');
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const handleAuthSubmit = async (email: string, pass: string, name?: string, passConfirm?: string) => {
|
|
197
|
+
if (!email || !pass) return;
|
|
198
|
+
if (authStatus === 'setup' && (!name || pass !== passConfirm)) {
|
|
199
|
+
setAuthError(name ? "Passwords do not match" : "Name required");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const endpoint = authStatus === 'setup' ? '/api/auth/setup' : '/api/auth/login';
|
|
204
|
+
const payload = authStatus === 'setup'
|
|
205
|
+
? { name, email, password: pass }
|
|
206
|
+
: { email, password: pass };
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const res = await fetch(endpoint, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: { 'Content-Type': 'application/json' },
|
|
212
|
+
body: JSON.stringify(payload)
|
|
213
|
+
});
|
|
214
|
+
if (res.ok) {
|
|
215
|
+
setAuthError('');
|
|
216
|
+
await checkAuth();
|
|
217
|
+
navigate('/');
|
|
218
|
+
} else {
|
|
219
|
+
setAuthError(authStatus === 'setup' ? "Setup failed" : "Invalid credentials");
|
|
220
|
+
}
|
|
221
|
+
} catch (e) {
|
|
222
|
+
setAuthError("Network error");
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const loadTasks = async () => {
|
|
227
|
+
try {
|
|
228
|
+
const res = await fetch('/api/tasks');
|
|
229
|
+
const data = await res.json();
|
|
230
|
+
const sorted = [...data].sort((a: Task, b: Task) => (b.last_opened || 0) - (a.last_opened || 0));
|
|
231
|
+
setTasks(sorted);
|
|
232
|
+
return sorted;
|
|
233
|
+
} catch (e) {
|
|
234
|
+
console.error("Failed to load tasks", e);
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const logout = async () => {
|
|
240
|
+
const confirmed = await requestConfirm('Are you sure you want to log out?');
|
|
241
|
+
if (!confirmed) return;
|
|
242
|
+
await fetch('/api/auth/logout', { method: 'POST' });
|
|
243
|
+
setUser(null);
|
|
244
|
+
setAuthStatus('login');
|
|
245
|
+
navigate('/');
|
|
246
|
+
showAlert('Logged out.', 'success');
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
function buildNewTask(): Task {
|
|
250
|
+
return {
|
|
251
|
+
name: "Task " + Math.floor(Math.random() * 100),
|
|
252
|
+
url: "",
|
|
253
|
+
mode: "agent",
|
|
254
|
+
wait: 3,
|
|
255
|
+
selector: "",
|
|
256
|
+
rotateUserAgents: false,
|
|
257
|
+
humanTyping: false,
|
|
258
|
+
stealth: {
|
|
259
|
+
allowTypos: false,
|
|
260
|
+
idleMovements: false,
|
|
261
|
+
overscroll: false,
|
|
262
|
+
deadClicks: false,
|
|
263
|
+
fatigue: false,
|
|
264
|
+
naturalTyping: false
|
|
265
|
+
},
|
|
266
|
+
actions: [],
|
|
267
|
+
variables: {},
|
|
268
|
+
extractionFormat: 'json',
|
|
269
|
+
includeShadowDom: true
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const createNewTask = () => {
|
|
274
|
+
const newTask = buildNewTask();
|
|
275
|
+
setCurrentTask(newTask);
|
|
276
|
+
setResults(null);
|
|
277
|
+
navigate('/tasks/new');
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const pinResults = (data: Results) => {
|
|
281
|
+
if (!currentTask) return;
|
|
282
|
+
setPinnedResultsByTask((prev) => ({ ...prev, [currentTaskKey]: data }));
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const unpinResults = () => {
|
|
286
|
+
if (!currentTask) return;
|
|
287
|
+
setPinnedResultsByTask((prev) => {
|
|
288
|
+
const next = { ...prev };
|
|
289
|
+
delete next[currentTaskKey];
|
|
290
|
+
return next;
|
|
291
|
+
});
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const touchTask = async (id: string) => {
|
|
295
|
+
try {
|
|
296
|
+
await fetch(`/api/tasks/${id}/touch`, { method: 'POST' });
|
|
297
|
+
loadTasks();
|
|
298
|
+
} catch (e) {
|
|
299
|
+
console.error("Failed to touch task", e);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const editTask = (task: Task) => {
|
|
304
|
+
const migratedTask = { ...task };
|
|
305
|
+
if (!migratedTask.variables || Array.isArray(migratedTask.variables)) migratedTask.variables = {};
|
|
306
|
+
if (!migratedTask.stealth) {
|
|
307
|
+
migratedTask.stealth = {
|
|
308
|
+
allowTypos: false,
|
|
309
|
+
idleMovements: false,
|
|
310
|
+
overscroll: false,
|
|
311
|
+
deadClicks: false,
|
|
312
|
+
fatigue: false,
|
|
313
|
+
naturalTyping: false
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
if (!migratedTask.extractionFormat) migratedTask.extractionFormat = 'json';
|
|
317
|
+
if (migratedTask.includeShadowDom === undefined) migratedTask.includeShadowDom = true;
|
|
318
|
+
const normalized = ensureActionIds(migratedTask);
|
|
319
|
+
setCurrentTask(normalized);
|
|
320
|
+
setResults(null);
|
|
321
|
+
navigate(`/tasks/${task.id}`);
|
|
322
|
+
if (task.id) touchTask(task.id);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const deleteTask = async (id: string) => {
|
|
326
|
+
if (!await requestConfirm('Are you sure you want to delete this task?')) return;
|
|
327
|
+
await fetch(`/api/tasks/${id}`, { method: 'DELETE' });
|
|
328
|
+
loadTasks();
|
|
329
|
+
if (location.pathname.includes(id)) {
|
|
330
|
+
navigate('/dashboard');
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const saveTask = async () => {
|
|
335
|
+
if (!currentTask) return;
|
|
336
|
+
const taskToSave = { ...currentTask, last_opened: Date.now() };
|
|
337
|
+
const res = await fetch('/api/tasks', {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
headers: { 'Content-Type': 'application/json' },
|
|
340
|
+
body: JSON.stringify(taskToSave)
|
|
341
|
+
});
|
|
342
|
+
const saved = await res.json();
|
|
343
|
+
setCurrentTask(saved);
|
|
344
|
+
setSaveMsg("SAVED");
|
|
345
|
+
setTimeout(() => setSaveMsg(''), 2000);
|
|
346
|
+
loadTasks();
|
|
347
|
+
if (location.pathname.includes('new')) {
|
|
348
|
+
navigate(`/tasks/${saved.id}`, { replace: true });
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const stopHeadful = async () => {
|
|
353
|
+
try {
|
|
354
|
+
await fetch('/headful/stop', { method: 'POST' });
|
|
355
|
+
} catch (e) {
|
|
356
|
+
console.error('Failed to stop headful session', e);
|
|
357
|
+
} finally {
|
|
358
|
+
setIsExecuting(false);
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const stopTask = async () => {
|
|
363
|
+
if (currentTask?.mode === 'headful') {
|
|
364
|
+
await stopHeadful();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (activeRunId) {
|
|
368
|
+
try {
|
|
369
|
+
await fetch('/api/executions/stop', {
|
|
370
|
+
method: 'POST',
|
|
371
|
+
headers: { 'Content-Type': 'application/json' },
|
|
372
|
+
body: JSON.stringify({ runId: activeRunId })
|
|
373
|
+
});
|
|
374
|
+
} catch (e) {
|
|
375
|
+
console.error('Failed to request stop', e);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (executeAbortRef.current) {
|
|
379
|
+
executeAbortRef.current.abort();
|
|
380
|
+
}
|
|
381
|
+
setIsExecuting(false);
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const runTaskWithSnapshot = async (taskOverride?: Task) => {
|
|
385
|
+
const taskToRunRaw = taskOverride || currentTask;
|
|
386
|
+
if (!taskToRunRaw || !taskToRunRaw.url) return;
|
|
387
|
+
const taskToRun = ensureActionIds(taskToRunRaw);
|
|
388
|
+
if (currentTask && taskToRun !== currentTask) {
|
|
389
|
+
setCurrentTask(taskToRun);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const runId = `run_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
393
|
+
setActiveRunId(runId);
|
|
394
|
+
|
|
395
|
+
if (isExecuting && taskToRun.mode === 'headful') {
|
|
396
|
+
await stopHeadful();
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
setIsExecuting(true);
|
|
401
|
+
setResults({
|
|
402
|
+
url: taskToRun.url,
|
|
403
|
+
logs: [],
|
|
404
|
+
timestamp: 'Running...',
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
let payload: any = null;
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
const cleanedVars: Record<string, any> = {};
|
|
411
|
+
Object.entries(taskToRun.variables).forEach(([name, def]) => {
|
|
412
|
+
cleanedVars[name] = def.value;
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const resolveTemplate = (input: string) => {
|
|
416
|
+
return input.replace(/\{\$(\w+)\}/g, (_match, name) => {
|
|
417
|
+
if (name === 'now') return new Date().toISOString();
|
|
418
|
+
const value = cleanedVars[name];
|
|
419
|
+
if (value === undefined || value === null || value === '') return '';
|
|
420
|
+
return String(value);
|
|
421
|
+
});
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const resolveMaybe = (value?: string) => {
|
|
425
|
+
if (typeof value !== 'string') return value;
|
|
426
|
+
return resolveTemplate(value);
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const shouldResolve = taskToRun.mode !== 'agent';
|
|
430
|
+
const resolvedTask = {
|
|
431
|
+
...taskToRun,
|
|
432
|
+
url: shouldResolve ? resolveTemplate(taskToRun.url || '') : (taskToRun.url || ''),
|
|
433
|
+
selector: shouldResolve ? resolveMaybe(taskToRun.selector) : taskToRun.selector,
|
|
434
|
+
actions: shouldResolve
|
|
435
|
+
? taskToRun.actions.map((action) => ({
|
|
436
|
+
...action,
|
|
437
|
+
selector: resolveMaybe(action.selector),
|
|
438
|
+
value: resolveMaybe(action.value),
|
|
439
|
+
key: resolveMaybe(action.key)
|
|
440
|
+
}))
|
|
441
|
+
: taskToRun.actions
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
payload = {
|
|
445
|
+
...resolvedTask,
|
|
446
|
+
taskVariables: cleanedVars,
|
|
447
|
+
variables: cleanedVars,
|
|
448
|
+
runSource: 'editor',
|
|
449
|
+
taskId: taskToRun.id,
|
|
450
|
+
taskName: taskToRun.name,
|
|
451
|
+
taskSnapshot: taskToRun,
|
|
452
|
+
runId
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const executeTask = async (mode: 'scrape' | 'agent' | 'headful') => {
|
|
456
|
+
const controller = new AbortController();
|
|
457
|
+
executeAbortRef.current = controller;
|
|
458
|
+
const res = await fetch(`/${mode}`, {
|
|
459
|
+
method: 'POST',
|
|
460
|
+
headers: { 'Content-Type': 'application/json' },
|
|
461
|
+
body: JSON.stringify(payload),
|
|
462
|
+
signal: controller.signal
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
if (!res.ok) {
|
|
466
|
+
let errorData: any = null;
|
|
467
|
+
try {
|
|
468
|
+
errorData = await res.json();
|
|
469
|
+
} catch {
|
|
470
|
+
errorData = null;
|
|
471
|
+
}
|
|
472
|
+
const error = new Error(errorData?.details || errorData?.error || "Request failed");
|
|
473
|
+
(error as any).code = errorData?.error;
|
|
474
|
+
throw error;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return res.json();
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const data = await executeTask(taskToRun.mode);
|
|
481
|
+
|
|
482
|
+
setResults({
|
|
483
|
+
url: taskToRun.url,
|
|
484
|
+
finalUrl: data.final_url,
|
|
485
|
+
html: data.html,
|
|
486
|
+
data: data.data ?? data.html ?? "No data captured.",
|
|
487
|
+
screenshotUrl: data.screenshot_url,
|
|
488
|
+
logs: data.logs || [],
|
|
489
|
+
timestamp: new Date().toLocaleTimeString(),
|
|
490
|
+
});
|
|
491
|
+
} catch (e: any) {
|
|
492
|
+
if (e?.name === 'AbortError') {
|
|
493
|
+
showAlert('Execution stopped.', 'success');
|
|
494
|
+
setIsExecuting(false);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (
|
|
498
|
+
taskToRun?.mode === 'headful'
|
|
499
|
+
&& payload
|
|
500
|
+
&& (e?.code === 'HEADFUL_DISPLAY_UNAVAILABLE' || isDisplayUnavailable(e?.message || String(e)))
|
|
501
|
+
) {
|
|
502
|
+
try {
|
|
503
|
+
const data = await (async () => {
|
|
504
|
+
const controller = new AbortController();
|
|
505
|
+
executeAbortRef.current = controller;
|
|
506
|
+
const res = await fetch(`/scrape`, {
|
|
507
|
+
method: 'POST',
|
|
508
|
+
headers: { 'Content-Type': 'application/json' },
|
|
509
|
+
body: JSON.stringify(payload),
|
|
510
|
+
signal: controller.signal
|
|
511
|
+
});
|
|
512
|
+
if (!res.ok) {
|
|
513
|
+
const errorData = await res.json();
|
|
514
|
+
throw new Error(errorData.details || errorData.error || "Request failed");
|
|
515
|
+
}
|
|
516
|
+
return res.json();
|
|
517
|
+
})();
|
|
518
|
+
data.logs = [`Headful display unavailable; ran headless instead.`, ...(data.logs || [])];
|
|
519
|
+
setResults({
|
|
520
|
+
url: taskToRun.url,
|
|
521
|
+
finalUrl: data.final_url,
|
|
522
|
+
html: data.html,
|
|
523
|
+
data: data.data ?? data.html ?? "No data captured.",
|
|
524
|
+
screenshotUrl: data.screenshot_url,
|
|
525
|
+
logs: data.logs || [],
|
|
526
|
+
timestamp: new Date().toLocaleTimeString(),
|
|
527
|
+
});
|
|
528
|
+
setIsExecuting(false);
|
|
529
|
+
return;
|
|
530
|
+
} catch (fallbackError: any) {
|
|
531
|
+
const errorMessage = formatExecutionError(fallbackError?.message || String(fallbackError), taskToRun?.mode);
|
|
532
|
+
showAlert(`Execution crash: ${errorMessage}`, 'error');
|
|
533
|
+
setIsExecuting(false);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
const errorMessage = formatExecutionError(e?.message || String(e), taskToRun?.mode);
|
|
538
|
+
showAlert(`Execution crash: ${errorMessage}`, 'error');
|
|
539
|
+
if (taskToRun?.mode === 'headful') {
|
|
540
|
+
setIsExecuting(false);
|
|
541
|
+
}
|
|
542
|
+
} finally {
|
|
543
|
+
executeAbortRef.current = null;
|
|
544
|
+
if (taskToRun.mode !== 'headful') {
|
|
545
|
+
setIsExecuting(false);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const runTask = async () => {
|
|
551
|
+
await runTaskWithSnapshot();
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
const clearStorage = async (type: 'screenshots' | 'cookies') => {
|
|
555
|
+
if (!await requestConfirm(`Delete all ${type}?`)) return;
|
|
556
|
+
const endpoint = type === 'screenshots' ? '/api/clear-screenshots' : '/api/clear-cookies';
|
|
557
|
+
await fetch(endpoint, { method: 'POST' });
|
|
558
|
+
showAlert(`${formatLabel(type)} cleared.`, 'success');
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const exportTasks = () => {
|
|
562
|
+
const payload = {
|
|
563
|
+
exportedAt: new Date().toISOString(),
|
|
564
|
+
tasks
|
|
565
|
+
};
|
|
566
|
+
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
|
567
|
+
const url = URL.createObjectURL(blob);
|
|
568
|
+
const link = document.createElement('a');
|
|
569
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
570
|
+
link.href = url;
|
|
571
|
+
link.download = `doppelganger-tasks-${stamp}.json`;
|
|
572
|
+
document.body.appendChild(link);
|
|
573
|
+
link.click();
|
|
574
|
+
link.remove();
|
|
575
|
+
URL.revokeObjectURL(url);
|
|
576
|
+
showAlert('Tasks exported.', 'success');
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const normalizeImportedTask = (raw: any, index: number): Task | null => {
|
|
580
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
581
|
+
const base = makeDefaultTask();
|
|
582
|
+
const merged: Task = { ...base, ...raw };
|
|
583
|
+
if (!merged.name || typeof merged.name !== 'string') {
|
|
584
|
+
merged.name = `Imported Task ${index + 1}`;
|
|
585
|
+
}
|
|
586
|
+
if (!merged.mode || !['scrape', 'agent', 'headful'].includes(merged.mode)) {
|
|
587
|
+
merged.mode = 'scrape';
|
|
588
|
+
}
|
|
589
|
+
if (typeof merged.wait !== 'number') merged.wait = 3;
|
|
590
|
+
if (!merged.stealth) merged.stealth = base.stealth;
|
|
591
|
+
if (!merged.variables || Array.isArray(merged.variables)) merged.variables = {};
|
|
592
|
+
if (!Array.isArray(merged.actions)) merged.actions = [];
|
|
593
|
+
delete merged.versions;
|
|
594
|
+
delete merged.last_opened;
|
|
595
|
+
return merged;
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
const importTasks = async (file: File) => {
|
|
599
|
+
try {
|
|
600
|
+
const text = await file.text();
|
|
601
|
+
const parsed = JSON.parse(text);
|
|
602
|
+
const list = Array.isArray(parsed) ? parsed : parsed?.tasks;
|
|
603
|
+
if (!Array.isArray(list)) {
|
|
604
|
+
showAlert('Invalid import file.', 'error');
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const stamp = Date.now();
|
|
608
|
+
const prepared = list
|
|
609
|
+
.map((raw, index) => normalizeImportedTask(raw, index))
|
|
610
|
+
.filter((task): task is Task => !!task)
|
|
611
|
+
.map((task) => (task.id ? task : { ...task, id: `task_${stamp}` }));
|
|
612
|
+
|
|
613
|
+
if (prepared.length === 0) {
|
|
614
|
+
showAlert('No tasks to import.', 'error');
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
await Promise.all(prepared.map((task) => (
|
|
619
|
+
fetch('/api/tasks', {
|
|
620
|
+
method: 'POST',
|
|
621
|
+
headers: { 'Content-Type': 'application/json' },
|
|
622
|
+
body: JSON.stringify(task)
|
|
623
|
+
})
|
|
624
|
+
)));
|
|
625
|
+
await loadTasks();
|
|
626
|
+
showAlert(`Imported ${prepared.length} task(s).`, 'success');
|
|
627
|
+
} catch (e: any) {
|
|
628
|
+
showAlert(`Import failed: ${e.message || 'Unknown error'}`, 'error');
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
const getCurrentScreen = () => {
|
|
633
|
+
if (location.pathname.startsWith('/tasks')) return 'editor';
|
|
634
|
+
if (location.pathname === '/settings') return 'settings';
|
|
635
|
+
if (location.pathname === '/executions') return 'executions';
|
|
636
|
+
return 'dashboard';
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
let content: React.ReactNode;
|
|
640
|
+
if (authStatus === 'login' || authStatus === 'setup') {
|
|
641
|
+
content = <AuthScreen status={authStatus} onSubmit={handleAuthSubmit} error={authError} />;
|
|
642
|
+
} else if (authStatus === 'checking') {
|
|
643
|
+
content = <LoadingScreen title="Authenticating" subtitle="Verifying session state" />;
|
|
644
|
+
} else {
|
|
645
|
+
content = (
|
|
646
|
+
<div className="h-full flex flex-row overflow-hidden bg-[#020202]">
|
|
647
|
+
<Sidebar
|
|
648
|
+
onNavigate={(s) => {
|
|
649
|
+
if (s === 'dashboard') navigate('/dashboard');
|
|
650
|
+
else if (s === 'settings') {
|
|
651
|
+
navigate('/settings');
|
|
652
|
+
} else if (s === 'executions') {
|
|
653
|
+
navigate('/executions');
|
|
654
|
+
}
|
|
655
|
+
}}
|
|
656
|
+
onNewTask={createNewTask}
|
|
657
|
+
onLogout={logout}
|
|
658
|
+
currentScreen={getCurrentScreen()}
|
|
659
|
+
/>
|
|
660
|
+
|
|
661
|
+
<Routes>
|
|
662
|
+
<Route path="/" element={<DashboardScreen tasks={tasks} onNewTask={createNewTask} onEditTask={editTask} onDeleteTask={deleteTask} onExportTasks={exportTasks} onImportTasks={importTasks} />} />
|
|
663
|
+
<Route path="/dashboard" element={<DashboardScreen tasks={tasks} onNewTask={createNewTask} onEditTask={editTask} onDeleteTask={deleteTask} onExportTasks={exportTasks} onImportTasks={importTasks} />} />
|
|
664
|
+
<Route path="/tasks/new" element={
|
|
665
|
+
currentTask ? (
|
|
666
|
+
<EditorScreen
|
|
667
|
+
currentTask={currentTask}
|
|
668
|
+
setCurrentTask={setCurrentTask}
|
|
669
|
+
tasks={tasks}
|
|
670
|
+
editorView={editorView}
|
|
671
|
+
setEditorView={setEditorView}
|
|
672
|
+
isExecuting={isExecuting}
|
|
673
|
+
onSave={saveTask}
|
|
674
|
+
onRun={runTask}
|
|
675
|
+
onRunSnapshot={runTaskWithSnapshot}
|
|
676
|
+
results={results}
|
|
677
|
+
pinnedResults={pinnedResults}
|
|
678
|
+
saveMsg={saveMsg}
|
|
679
|
+
onConfirm={requestConfirm}
|
|
680
|
+
onNotify={showAlert}
|
|
681
|
+
onPinResults={pinResults}
|
|
682
|
+
onUnpinResults={unpinResults}
|
|
683
|
+
runId={activeRunId}
|
|
684
|
+
onStop={stopTask}
|
|
685
|
+
/>
|
|
686
|
+
) : <LoadingScreen title="Initializing" subtitle="Preparing task workspace" />
|
|
687
|
+
} />
|
|
688
|
+
<Route path="/tasks/:id" element={<EditorLoader tasks={tasks} loadTasks={loadTasks} touchTask={touchTask} currentTask={currentTask} setCurrentTask={setCurrentTask} editorView={editorView} setEditorView={setEditorView} isExecuting={isExecuting} onSave={saveTask} onRun={runTask} onRunSnapshot={runTaskWithSnapshot} results={results} pinnedResults={pinnedResults} saveMsg={saveMsg} onConfirm={requestConfirm} onNotify={showAlert} onPinResults={pinResults} onUnpinResults={unpinResults} runId={activeRunId} onStop={stopTask} />} />
|
|
689
|
+
<Route path="/settings" element={
|
|
690
|
+
<SettingsScreen
|
|
691
|
+
onClearStorage={clearStorage}
|
|
692
|
+
onConfirm={requestConfirm}
|
|
693
|
+
onNotify={showAlert}
|
|
694
|
+
/>
|
|
695
|
+
} />
|
|
696
|
+
<Route path="/executions" element={<ExecutionsScreen onConfirm={requestConfirm} onNotify={showAlert} />} />
|
|
697
|
+
<Route path="/executions/:id" element={<ExecutionDetailScreen onConfirm={requestConfirm} onNotify={showAlert} />} />
|
|
698
|
+
<Route path="*" element={<NotFoundScreen onBack={() => navigate('/dashboard')} />} />
|
|
699
|
+
</Routes>
|
|
700
|
+
</div>
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return (
|
|
705
|
+
<div className="h-full">
|
|
706
|
+
{centerAlert && (
|
|
707
|
+
<CenterAlert
|
|
708
|
+
message={centerAlert.message}
|
|
709
|
+
tone={centerAlert.tone}
|
|
710
|
+
onClose={() => setCenterAlert(null)}
|
|
711
|
+
/>
|
|
712
|
+
)}
|
|
713
|
+
{centerConfirm && (
|
|
714
|
+
<CenterConfirm
|
|
715
|
+
request={centerConfirm}
|
|
716
|
+
onResolve={closeConfirm}
|
|
717
|
+
/>
|
|
718
|
+
)}
|
|
719
|
+
{content}
|
|
720
|
+
</div>
|
|
721
|
+
);
|
|
722
|
+
}
|