@proagentstore/cli 0.1.4 → 0.1.6
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.
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
3
|
-
import {
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
import { RunnerStore } from "./store.js";
|
|
7
7
|
const CAPABILITIES = [
|
|
@@ -11,7 +11,7 @@ const CAPABILITIES = [
|
|
|
11
11
|
"file.upload",
|
|
12
12
|
"human.approval",
|
|
13
13
|
];
|
|
14
|
-
const APPROVAL_REQUIRED_TASKS = new Set(["browser.open"]);
|
|
14
|
+
const APPROVAL_REQUIRED_TASKS = new Set(["browser.open", "job.apply_basic"]);
|
|
15
15
|
const require = createRequire(import.meta.url);
|
|
16
16
|
export class RunnerInputError extends Error {
|
|
17
17
|
status = 400;
|
|
@@ -32,8 +32,8 @@ export class LocalRunner {
|
|
|
32
32
|
brainPlacement: "pags",
|
|
33
33
|
runnerRole: "tool-executor",
|
|
34
34
|
capabilities: CAPABILITIES,
|
|
35
|
-
taskTypes: ["echo", "browser.open"],
|
|
36
|
-
approvalRequiredFor: [
|
|
35
|
+
taskTypes: ["echo", "browser.open", "job.apply_basic"],
|
|
36
|
+
approvalRequiredFor: [...APPROVAL_REQUIRED_TASKS],
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
39
|
createTask(request) {
|
|
@@ -160,8 +160,36 @@ export class LocalRunner {
|
|
|
160
160
|
title: await page.title(),
|
|
161
161
|
};
|
|
162
162
|
}
|
|
163
|
+
if (task.type === "job.apply_basic") {
|
|
164
|
+
return this.applyToBasicJob(task.input);
|
|
165
|
+
}
|
|
163
166
|
throw new Error(`Unknown task type: ${task.type}`);
|
|
164
167
|
}
|
|
168
|
+
async applyToBasicJob(input) {
|
|
169
|
+
const job = normalizeJobApplicationInput(input);
|
|
170
|
+
const context = await this.getBrowserContext();
|
|
171
|
+
const page = context.pages()[0] || (await context.newPage());
|
|
172
|
+
await page.goto(job.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
173
|
+
const filledFields = await fillBasicApplicationForm(page, job);
|
|
174
|
+
const beforeSubmitUrl = page.url();
|
|
175
|
+
const navigation = page.waitForNavigation({
|
|
176
|
+
waitUntil: "domcontentloaded",
|
|
177
|
+
timeout: 15_000,
|
|
178
|
+
}).catch(() => null);
|
|
179
|
+
await submitApplicationForm(page);
|
|
180
|
+
await navigation;
|
|
181
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 5_000 }).catch(() => undefined);
|
|
182
|
+
return {
|
|
183
|
+
taskType: "job.apply_basic",
|
|
184
|
+
submitted: true,
|
|
185
|
+
beforeSubmitUrl,
|
|
186
|
+
finalUrl: page.url(),
|
|
187
|
+
title: await page.title(),
|
|
188
|
+
fieldsFilled: filledFields,
|
|
189
|
+
resumeFile: basename(job.resumePath),
|
|
190
|
+
visibleText: (await page.locator("body").innerText({ timeout: 5_000 }).catch(() => "")).slice(0, 2_000),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
165
193
|
requireTask(id) {
|
|
166
194
|
const task = this.store.getTask(id);
|
|
167
195
|
if (!task)
|
|
@@ -202,9 +230,164 @@ export class LocalRunner {
|
|
|
202
230
|
}
|
|
203
231
|
}
|
|
204
232
|
}
|
|
233
|
+
export function normalizeJobApplicationInput(input) {
|
|
234
|
+
const url = stringValue(input.url);
|
|
235
|
+
if (!url || !/^https?:\/\//.test(url)) {
|
|
236
|
+
throw new RunnerInputError("job.apply_basic requires an http(s) url");
|
|
237
|
+
}
|
|
238
|
+
const resumePath = resolve(stringValue(input.resumePath));
|
|
239
|
+
if (!resumePath || !existsSync(resumePath)) {
|
|
240
|
+
throw new RunnerInputError("job.apply_basic requires an existing resumePath");
|
|
241
|
+
}
|
|
242
|
+
const candidate = isRecord(input.candidate) ? input.candidate : {};
|
|
243
|
+
const fullName = stringValue(candidate.fullName);
|
|
244
|
+
const email = stringValue(candidate.email);
|
|
245
|
+
if (!fullName)
|
|
246
|
+
throw new RunnerInputError("candidate.fullName is required");
|
|
247
|
+
if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
|
|
248
|
+
throw new RunnerInputError("candidate.email must be a valid email address");
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
url,
|
|
252
|
+
resumePath,
|
|
253
|
+
candidate: {
|
|
254
|
+
fullName,
|
|
255
|
+
email,
|
|
256
|
+
phone: optionalString(candidate.phone),
|
|
257
|
+
location: optionalString(candidate.location),
|
|
258
|
+
linkedin: optionalString(candidate.linkedin),
|
|
259
|
+
portfolio: optionalString(candidate.portfolio),
|
|
260
|
+
workAuthorization: optionalString(candidate.workAuthorization),
|
|
261
|
+
},
|
|
262
|
+
coverNote: optionalString(input.coverNote),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
async function fillBasicApplicationForm(page, job) {
|
|
266
|
+
const filled = [];
|
|
267
|
+
const candidate = job.candidate;
|
|
268
|
+
const fillSpecs = [
|
|
269
|
+
["fullName", candidate.fullName, [
|
|
270
|
+
'input[name="fullName"]',
|
|
271
|
+
'input[name="full_name"]',
|
|
272
|
+
'input[name="name"]',
|
|
273
|
+
'input[autocomplete="name"]',
|
|
274
|
+
]],
|
|
275
|
+
["email", candidate.email, [
|
|
276
|
+
'input[name="email"]',
|
|
277
|
+
'input[type="email"]',
|
|
278
|
+
'input[autocomplete="email"]',
|
|
279
|
+
]],
|
|
280
|
+
["phone", candidate.phone, [
|
|
281
|
+
'input[name="phone"]',
|
|
282
|
+
'input[name="mobile"]',
|
|
283
|
+
'input[autocomplete="tel"]',
|
|
284
|
+
]],
|
|
285
|
+
["location", candidate.location, [
|
|
286
|
+
'input[name="location"]',
|
|
287
|
+
'input[autocomplete="address-level2"]',
|
|
288
|
+
'input[autocomplete="address-level1"]',
|
|
289
|
+
]],
|
|
290
|
+
["linkedin", candidate.linkedin, [
|
|
291
|
+
'input[name="linkedin"]',
|
|
292
|
+
'input[name="linkedIn"]',
|
|
293
|
+
'input[placeholder*="LinkedIn" i]',
|
|
294
|
+
]],
|
|
295
|
+
["portfolio", candidate.portfolio, [
|
|
296
|
+
'input[name="portfolio"]',
|
|
297
|
+
'input[name="website"]',
|
|
298
|
+
'input[placeholder*="Portfolio" i]',
|
|
299
|
+
'input[placeholder*="Website" i]',
|
|
300
|
+
]],
|
|
301
|
+
["coverNote", job.coverNote, [
|
|
302
|
+
'textarea[name="coverNote"]',
|
|
303
|
+
'textarea[name="cover_letter"]',
|
|
304
|
+
'textarea[name="coverLetter"]',
|
|
305
|
+
'textarea[placeholder*="cover" i]',
|
|
306
|
+
'textarea[placeholder*="message" i]',
|
|
307
|
+
]],
|
|
308
|
+
];
|
|
309
|
+
for (const [name, value, selectors] of fillSpecs) {
|
|
310
|
+
if (value && await fillFirst(page, selectors, value))
|
|
311
|
+
filled.push(name);
|
|
312
|
+
}
|
|
313
|
+
if (candidate.workAuthorization && await selectOrFillFirst(page, [
|
|
314
|
+
'select[name="workAuthorization"]',
|
|
315
|
+
'select[name="work_authorization"]',
|
|
316
|
+
'select[name*="authorization" i]',
|
|
317
|
+
], candidate.workAuthorization)) {
|
|
318
|
+
filled.push("workAuthorization");
|
|
319
|
+
}
|
|
320
|
+
const resumeInput = page.locator('input[type="file"][name*="resume" i], input[type="file"][name*="cv" i], input[type="file"]').first();
|
|
321
|
+
if (await resumeInput.count() === 0) {
|
|
322
|
+
throw new Error("No resume upload field found on the application form");
|
|
323
|
+
}
|
|
324
|
+
await resumeInput.setInputFiles(job.resumePath);
|
|
325
|
+
filled.push("resume");
|
|
326
|
+
return filled;
|
|
327
|
+
}
|
|
328
|
+
async function fillFirst(page, selectors, value) {
|
|
329
|
+
for (const selector of selectors) {
|
|
330
|
+
const locator = page.locator(selector).first();
|
|
331
|
+
if (await locator.count() === 0)
|
|
332
|
+
continue;
|
|
333
|
+
try {
|
|
334
|
+
await locator.fill(value, { timeout: 5_000 });
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
/* try next selector */
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
async function selectOrFillFirst(page, selectors, value) {
|
|
344
|
+
for (const selector of selectors) {
|
|
345
|
+
const locator = page.locator(selector).first();
|
|
346
|
+
if (await locator.count() === 0)
|
|
347
|
+
continue;
|
|
348
|
+
try {
|
|
349
|
+
await locator.selectOption({ label: value }, { timeout: 5_000 });
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
try {
|
|
354
|
+
await locator.fill(value, { timeout: 5_000 });
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
/* try next selector */
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
async function submitApplicationForm(page) {
|
|
365
|
+
const submit = page.locator('form button[type="submit"], form input[type="submit"], button:has-text("Submit"), input[type="submit"]').first();
|
|
366
|
+
if (await submit.count() > 0) {
|
|
367
|
+
await submit.click({ timeout: 10_000 });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const form = page.locator("form").first();
|
|
371
|
+
if (await form.count() === 0)
|
|
372
|
+
throw new Error("No application form found to submit");
|
|
373
|
+
await form.evaluate((node) => {
|
|
374
|
+
const htmlForm = node;
|
|
375
|
+
if (typeof htmlForm.requestSubmit === "function")
|
|
376
|
+
htmlForm.requestSubmit();
|
|
377
|
+
else
|
|
378
|
+
htmlForm.submit();
|
|
379
|
+
});
|
|
380
|
+
}
|
|
205
381
|
function isRecord(value) {
|
|
206
382
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
207
383
|
}
|
|
384
|
+
function stringValue(value) {
|
|
385
|
+
return String(value || "").trim();
|
|
386
|
+
}
|
|
387
|
+
function optionalString(value) {
|
|
388
|
+
const text = stringValue(value);
|
|
389
|
+
return text || undefined;
|
|
390
|
+
}
|
|
208
391
|
function normalizeCreateTaskRequest(request) {
|
|
209
392
|
if (!isRecord(request) || typeof request.type !== "string" || !request.type.trim()) {
|
|
210
393
|
throw new RunnerInputError("task type required");
|