qiksy 1.0.0

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.
Files changed (78) hide show
  1. package/ARCHITECTURE.md +78 -0
  2. package/INSTALL-PROMPT.md +53 -0
  3. package/bin/qiksy.mjs +302 -0
  4. package/package.json +32 -0
  5. package/tools/client-finder/vendor/README.md +70 -0
  6. package/tools/client-finder/vendor/find.mjs +121 -0
  7. package/tools/client-finder/vendor/icp/assist-pl-shops.json +30 -0
  8. package/tools/client-finder/vendor/icp/assist-ua-shops.json +30 -0
  9. package/tools/client-finder/vendor/knowledge/assist.md +68 -0
  10. package/tools/client-finder/vendor/knowledge/custom.md +36 -0
  11. package/tools/client-finder/vendor/knowledge/multilogin.md +29 -0
  12. package/tools/client-finder/vendor/knowledge/pay.md +26 -0
  13. package/tools/client-finder/vendor/knowledge/qa-copilot.md +43 -0
  14. package/tools/client-finder/vendor/knowledge/travel.md +43 -0
  15. package/tools/client-finder/vendor/knowledge/work-finder.md +52 -0
  16. package/tools/client-finder/vendor/lib/access.mjs +108 -0
  17. package/tools/client-finder/vendor/lib/agent.mjs +204 -0
  18. package/tools/client-finder/vendor/lib/approver-mcp.mjs +78 -0
  19. package/tools/client-finder/vendor/lib/browser.mjs +108 -0
  20. package/tools/client-finder/vendor/lib/catalog.mjs +86 -0
  21. package/tools/client-finder/vendor/lib/channels.mjs +54 -0
  22. package/tools/client-finder/vendor/lib/deliver.mjs +89 -0
  23. package/tools/client-finder/vendor/lib/engine.mjs +283 -0
  24. package/tools/client-finder/vendor/lib/formats.mjs +108 -0
  25. package/tools/client-finder/vendor/lib/jobs.mjs +75 -0
  26. package/tools/client-finder/vendor/lib/knowledge.mjs +26 -0
  27. package/tools/client-finder/vendor/lib/prompts.mjs +183 -0
  28. package/tools/client-finder/vendor/lib/report.mjs +75 -0
  29. package/tools/client-finder/vendor/lib/scope.mjs +162 -0
  30. package/tools/client-finder/vendor/lib/spawn-claude.mjs +44 -0
  31. package/tools/client-finder/vendor/lib/verify.mjs +96 -0
  32. package/tools/client-finder/vendor/package.json +8 -0
  33. package/tools/client-finder/vendor/server.mjs +390 -0
  34. package/tools/travel/vendor/README.md +58 -0
  35. package/tools/travel/vendor/docs/CONTRACT.md +174 -0
  36. package/tools/travel/vendor/docs/ENGINE-MIGRATION.md +33 -0
  37. package/tools/travel/vendor/lib/agent.mjs +190 -0
  38. package/tools/travel/vendor/lib/engine.mjs +283 -0
  39. package/tools/travel/vendor/lib/jobs.mjs +125 -0
  40. package/tools/travel/vendor/lib/license.mjs +41 -0
  41. package/tools/travel/vendor/lib/plans.mjs +28 -0
  42. package/tools/travel/vendor/lib/prompts.mjs +394 -0
  43. package/tools/travel/vendor/lib/proposal.mjs +207 -0
  44. package/tools/travel/vendor/lib/spawn-claude.mjs +44 -0
  45. package/tools/travel/vendor/lib/store.mjs +75 -0
  46. package/tools/travel/vendor/package.json +12 -0
  47. package/tools/travel/vendor/public/app.js +996 -0
  48. package/tools/travel/vendor/public/index.html +112 -0
  49. package/tools/travel/vendor/public/styles.css +760 -0
  50. package/tools/travel/vendor/scripts/approver-mcp.mjs +80 -0
  51. package/tools/travel/vendor/scripts/launch-browser.mjs +77 -0
  52. package/tools/travel/vendor/server.mjs +755 -0
  53. package/tools/work-finder/vendor/.claude/settings.local.json +27 -0
  54. package/tools/work-finder/vendor/.mcp.json +9 -0
  55. package/tools/work-finder/vendor/CLAUDE.md +24 -0
  56. package/tools/work-finder/vendor/README.md +44 -0
  57. package/tools/work-finder/vendor/bin/qiksy-work-finder.mjs +127 -0
  58. package/tools/work-finder/vendor/lib/agent.mjs +159 -0
  59. package/tools/work-finder/vendor/lib/catalog.mjs +41 -0
  60. package/tools/work-finder/vendor/lib/channels.mjs +73 -0
  61. package/tools/work-finder/vendor/lib/engine.mjs +283 -0
  62. package/tools/work-finder/vendor/lib/jobs.mjs +127 -0
  63. package/tools/work-finder/vendor/lib/license.mjs +60 -0
  64. package/tools/work-finder/vendor/lib/linkcheck.mjs +72 -0
  65. package/tools/work-finder/vendor/lib/linkedin.mjs +22 -0
  66. package/tools/work-finder/vendor/lib/plans.mjs +33 -0
  67. package/tools/work-finder/vendor/lib/prompts.mjs +217 -0
  68. package/tools/work-finder/vendor/lib/spawn-claude.mjs +44 -0
  69. package/tools/work-finder/vendor/lib/store.mjs +125 -0
  70. package/tools/work-finder/vendor/package.json +41 -0
  71. package/tools/work-finder/vendor/public/app.js +1863 -0
  72. package/tools/work-finder/vendor/public/index.html +518 -0
  73. package/tools/work-finder/vendor/public/styles.css +930 -0
  74. package/tools/work-finder/vendor/scripts/approver-mcp.mjs +69 -0
  75. package/tools/work-finder/vendor/scripts/extract-linkedin.mjs +81 -0
  76. package/tools/work-finder/vendor/scripts/launch-browser.mjs +78 -0
  77. package/tools/work-finder/vendor/scripts/screenshot.mjs +17 -0
  78. package/tools/work-finder/vendor/server.mjs +1006 -0
@@ -0,0 +1,1006 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Work Finder — job-search assistant driven by headless Claude Code agents.
4
+ *
5
+ * npm start → http://localhost:5544
6
+ *
7
+ * The server exposes a small JSON API + static UI. Long operations (search,
8
+ * cover letters, browser auto-apply, LinkedIn import) run as background jobs
9
+ * that spawn `claude -p …` (see lib/agent.mjs) and are polled by the UI.
10
+ */
11
+ import { createServer } from 'node:http';
12
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
13
+ import { resolve, extname } from 'node:path';
14
+ import { spawn } from 'node:child_process';
15
+
16
+ import { load, save, uid, ROOT, DATA_DIR } from './lib/store.mjs';
17
+ import { runClaude, extractJson } from './lib/agent.mjs';
18
+ import { searchPrompt, coverLetterPrompt, autoApplyPrompt, importResumePrompt, leadsSearchPrompt, pitchPrompt, buildFromStoryPrompt } from './lib/prompts.mjs';
19
+ import { createJob, jobLog, listJobs, getJob, cancelJob, cancelActiveByLabel, jobEvents } from './lib/jobs.mjs';
20
+ import { ensureBrowser } from './scripts/launch-browser.mjs';
21
+ import { PLANS, SELF_SETTABLE_PLANS } from './lib/plans.mjs';
22
+ import { verifyLicense, licensePlanId, RECHECK_MS } from './lib/license.mjs';
23
+ import { ROLE_CATALOG, SENIORITY, LOCATIONS, detectRoles } from './lib/catalog.mjs';
24
+ import { checkMany, checkVacancyUrl } from './lib/linkcheck.mjs';
25
+ import { probeEngine, startLogin, submitLoginCode } from './lib/engine.mjs';
26
+ import { normalizeLinkedin } from './lib/linkedin.mjs';
27
+
28
+ const PORT = Number(process.env.WF_PORT) || 5544;
29
+ const PUBLIC_DIR = resolve(ROOT, 'public');
30
+ const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.svg': 'image/svg+xml' };
31
+
32
+ let db = load();
33
+
34
+ // ── тариф ──────────────────────────────────────────────────────────────────
35
+ // Единственная ось — число кандидатов (maxProfiles). Кредитов НЕТ: юзер работает
36
+ // на своём ключе Claude, частота запросов упирается в его тариф Claude.
37
+ //
38
+ // Эффективный тариф: `dev` (self-host) выставляется руками; платные start/pro
39
+ // приходят ТОЛЬКО из валидной лицензии (db.settings.licenseInfo). Ручной выбор
40
+ // плана в настройках ограничен free/dev — иначе на своей машине юзер выставил бы pro.
41
+ // `dev` (unlimited — the self-host escape hatch) is the OWNER's path, not a
42
+ // customer one: it unlocks ONLY when this backend is started with WF_DEV=1.
43
+ // Without the flag the value is inert even if it lands in db.json (old migration,
44
+ // a hand-edit), and the effective plan falls back to the license → free. This
45
+ // closes the one-click "Dev — no limits" bypass in Settings while keeping the
46
+ // owner's local unlimited a single env var away.
47
+ const DEV_UNLOCKED = /^(1|true|yes|on)$/i.test(process.env.WF_DEV || '');
48
+
49
+ function effectivePlanId() {
50
+ if (db.settings.plan === 'dev' && DEV_UNLOCKED) return 'dev';
51
+ return licensePlanId(db.settings.licenseInfo);
52
+ }
53
+ const plan = () => PLANS[effectivePlanId()] || PLANS.free;
54
+ /** Effective candidate limit. A paid (Pro) license bought as Team carries `seats`,
55
+ * which raises the limit above the plan's default of 1 — that's how "Team = Pro on N
56
+ * seats" works without a separate plan. Individual Pro / no seats → the plan default. */
57
+ function maxProfilesEff() {
58
+ const s = db.settings.licenseInfo?.seats;
59
+ if (effectivePlanId() === 'pro' && Number.isFinite(s) && s > 1) return s;
60
+ return plan().maxProfiles;
61
+ }
62
+
63
+ /** Проверить лицензию у воркера и закэшировать результат (best-effort). */
64
+ async function refreshLicense() {
65
+ const info = await verifyLicense(db.settings.license || '');
66
+ if (info) { // null = офлайн → оставляем прошлый кэш
67
+ db.settings.licenseInfo = info;
68
+ save(db);
69
+ }
70
+ return db.settings.licenseInfo;
71
+ }
72
+
73
+ /** Информационный лог стоимости токенов юзера (его собственный ключ). Не гейт. */
74
+ function logCost(label, costUsd) {
75
+ if (costUsd) db.usage.totalCostUsd = Math.round((db.usage.totalCostUsd + costUsd) * 1e4) / 1e4;
76
+ db.usage.log.push({ at: Date.now(), label, costUsd: costUsd ?? null });
77
+ if (db.usage.log.length > 200) db.usage.log.splice(0, db.usage.log.length - 200);
78
+ save(db);
79
+ }
80
+
81
+ function billingInfo() {
82
+ const p = plan();
83
+ const li = db.settings.licenseInfo;
84
+ return {
85
+ plan: effectivePlanId(),
86
+ planName: p.name,
87
+ manualPlan: db.settings.plan, // что выставлено руками (free/dev)
88
+ license: {
89
+ set: !!db.settings.license,
90
+ key: db.settings.license ? db.settings.license.slice(0, 6) + '…' : '',
91
+ plan: li && li.plan ? li.plan : 'free',
92
+ active: !!(li && li.active),
93
+ invalid: !!(li && li.invalid),
94
+ paidUntil: (li && li.paidUntil) || 0,
95
+ trial: !!(li && li.trial),
96
+ checkedAt: (li && li.checkedAt) || 0,
97
+ },
98
+ maxProfiles: isFinite(maxProfilesEff()) ? maxProfilesEff() : null,
99
+ maxRoles: isFinite(p.maxRoles) ? p.maxRoles : null,
100
+ seats: db.settings.licenseInfo?.seats || null, // Team seat count, if the license carries one
101
+ autoApply: p.autoApply,
102
+ leads: p.leads,
103
+ watch: p.watch, // scheduled monitoring (Pro) — the premium "find before the market" feature
104
+ watchCfg: { enabled: !!db.settings.watch?.enabled, everyHours: db.settings.watch?.everyHours || 6, lastRunAt: db.settings.watch?.lastRunAt || 0 },
105
+ devUnlocked: DEV_UNLOCKED, // owner's self-host escape hatch; hides the "Dev" plan option from customers
106
+
107
+ totalCostUsd: db.usage.totalCostUsd, // информативно: сколько потратил СВОЙ ключ
108
+ };
109
+ }
110
+
111
+ // ── job workers ────────────────────────────────────────────────────────────
112
+
113
+ /** Запись в историю поисков (хлебные крошки в UI). */
114
+ function createSearchRun(targets) {
115
+ const run = {
116
+ id: uid(),
117
+ at: Date.now(),
118
+ titles: targets.map(p => p.title),
119
+ profileIds: [...new Set(targets.map(p => p.profileId))],
120
+ vacancyIds: [],
121
+ added: 0,
122
+ };
123
+ db.searches.unshift(run);
124
+ if (db.searches.length > 50) db.searches.length = 50;
125
+ save(db);
126
+ return run;
127
+ }
128
+
129
+ // Юзер что-то СДЕЛАЛ с вакансией: сменил статус, написал письмо, отправил заявку,
130
+ // закрепил. Такие при каскадном удалении роли/кандидата НЕ трогаем — это его труд
131
+ // (та же логика, что в purgeHistory). Остальное — нетронутая находка, шум, уходит.
132
+ const isTouched = (v) => v.status !== 'new' || !!v.coverLetter || !!v.applyResult || !!v.pinned;
133
+
134
+ function startSearch(position, run = null) {
135
+ const profile = db.profiles.find(p => p.id === position.profileId) || db.profiles[0];
136
+ return createJob('search', `Поиск: ${position.title}`, async (job, signal) => {
137
+ const urlOf = it => it?.vacancy_url || it?.careers_url || null;
138
+ const keyOf = it => (it.company + '|' + it.vacancy_title).toLowerCase();
139
+ const seen = new Set(db.vacancies.map(v => keyOf(v)));
140
+ let added = 0;
141
+ let dead = 0;
142
+
143
+ // добавление одной вакансии (партиал во время поиска или из финального массива)
144
+ const push = it => {
145
+ const rawMatch = Number(it.match);
146
+ // `...it` is UNSANITIZED model output, so the server's identity/state fields go AFTER
147
+ // the spread — a hallucinated key (status/positionId/id/foundAt…) can never clobber
148
+ // them and mis-tag a vacancy under the wrong candidate or as already-sent. `match`
149
+ // is likewise clamped last (adversarial review 2026-07-25).
150
+ const vac = {
151
+ ...it,
152
+ id: uid(),
153
+ positionId: position.id,
154
+ status: 'new',
155
+ coverLetter: null,
156
+ applyResult: null,
157
+ foundAt: Date.now(),
158
+ match: Number.isFinite(rawMatch) ? Math.max(0, Math.min(100, Math.round(rawMatch))) : null,
159
+ };
160
+ db.vacancies.push(vac);
161
+ if (run) { run.vacancyIds.push(vac.id); run.added++; }
162
+ added++;
163
+ save(db);
164
+ // Push the full vacancy to the live stream so the theatre can flip in a real card
165
+ // the instant it's found — before the next /api/state poll reconciles it.
166
+ jobEvents.emit('event', { kind: 'vacancy', jobId: job.id, vacancy: vac, at: Date.now() });
167
+ };
168
+ const acceptable = it => it?.company && it?.vacancy_title && !seen.has(keyOf(it)) && (urlOf(it) || it.apply_email);
169
+
170
+ // партиалы: агент стримит `VACANCY: {...}` — проверяем ссылку и сразу кладём в базу
171
+ let chain = Promise.resolve();
172
+ const onPartial = it => {
173
+ chain = chain.then(async () => {
174
+ if (!acceptable(it)) return;
175
+ seen.add(keyOf(it));
176
+ const url = urlOf(it);
177
+ if (url) {
178
+ const chk = await checkVacancyUrl(url);
179
+ if (!chk.ok) { dead++; jobLog(job, `✂ ${it.company}: ${chk.reason}`); return; }
180
+ }
181
+ push(it);
182
+ jobLog(job, `+ ${it.company} — ${it.vacancy_title}`);
183
+ }).catch(() => { /* партиал не критичен */ });
184
+ };
185
+
186
+ /* ONE SWEEP. The search does not stop at a number — it stops when a sweep finds
187
+ nothing new. "Find 8 and stop" was our own ceiling, not the market's: the
188
+ owner asked for React roles worldwide and got four, because the prompt told
189
+ the agent to quit at eight and the freshness window was three days. */
190
+ /* Safety rails, because "everything" runs on the user's own Claude quota:
191
+ stop after two barren sweeps in a row, and never exceed maxSweeps. */
192
+ const maxSweeps = Math.max(1, db.settings.maxSweeps ?? 6);
193
+
194
+ const sweep = async (wave) => {
195
+ const exclude = db.vacancies.filter(v => v.positionId === position.id).map(v => v.company);
196
+ const before = added;
197
+ const r = await runClaude({
198
+ prompt: searchPrompt(position, profile, exclude, db.settings, { wave, waves: maxSweeps, canNarrow: effectivePlanId() !== 'free' }),
199
+ allowedTools: ['WebSearch', 'WebFetch'],
200
+ withBrowser: !!db.settings.searchViaBrowser,
201
+ model: db.settings.model,
202
+ timeoutMs: (db.settings.searchViaBrowser ? 25 : 15) * 60_000,
203
+ signal,
204
+ onPartial,
205
+ onEvent: line => jobLog(job, line),
206
+ });
207
+ await chain.catch(() => {}); // партиалы этой волны
208
+ if (r.ok) {
209
+ const items = extractJson(r.result);
210
+ if (Array.isArray(items)) {
211
+ const rest = items.filter(acceptable);
212
+ if (rest.length) {
213
+ jobLog(job, `проверяю ссылки (${rest.length})…`);
214
+ const linkResults = await checkMany(rest.map(urlOf));
215
+ for (const it of rest) {
216
+ if (!acceptable(it)) continue;
217
+ seen.add(keyOf(it));
218
+ const url = urlOf(it);
219
+ const chk = url ? linkResults.get(url) : null;
220
+ if (url && chk && !chk.ok) { dead++; jobLog(job, `✂ ${it.company}: ${chk.reason}`); continue; }
221
+ push(it);
222
+ }
223
+ }
224
+ }
225
+ }
226
+ if (r.costUsd) logCost(`Поиск: ${position.title} (волна ${wave})`, r.costUsd);
227
+ return { ok: r.ok, error: r.error, costUsd: r.costUsd || 0, gained: added - before };
228
+ };
229
+
230
+ let cost = 0;
231
+ let barren = 0;
232
+ let lastErr = null;
233
+ let wave = 0;
234
+ while (wave < maxSweeps && !signal.aborted) {
235
+ wave++;
236
+ if (wave > 1) jobLog(job, `волна ${wave} из ${maxSweeps} — ищу то, чего ещё нет…`);
237
+ const r = await sweep(wave);
238
+ cost += r.costUsd;
239
+ if (!r.ok) { lastErr = r.error; break; }
240
+ jobLog(job, `волна ${wave}: +${r.gained} (всего ${added})`);
241
+ barren = r.gained ? 0 : barren + 1;
242
+ if (barren >= 2) { jobLog(job, 'две волны подряд без новых — рынок вычерпан'); break; }
243
+ }
244
+
245
+ const res = { ok: !lastErr, error: lastErr, costUsd: cost, result: '[]' };
246
+ if (!res.ok) {
247
+ // таймаут/обрыв, но партиалы уже в базе — это частичный успех, не ошибка
248
+ if (added > 0) {
249
+ jobLog(job, `⚠ ${res.error} — но ${added} вакансий уже добавлено по ходу поиска`);
250
+ return { found: added, added, dead, sweeps: wave, costUsd: cost, truncated: true };
251
+ }
252
+ throw new Error(res.error);
253
+ }
254
+ jobLog(job, `✓ добавлено: ${added}${dead ? `, отброшено битых ссылок: ${dead}` : ''} — волн: ${wave}`);
255
+ return { found: added, added, dead, sweeps: wave, costUsd: cost };
256
+ });
257
+ }
258
+
259
+ function startCoverLetter(vacancy) {
260
+ const position = db.positions.find(p => p.id === vacancy.positionId);
261
+ const profile = db.profiles.find(p => p.id === position?.profileId) || db.profiles[0];
262
+ return createJob('prepare', `Отклик: ${vacancy.company} — ${vacancy.vacancy_title}`, async (job, signal) => {
263
+ const res = await runClaude({
264
+ prompt: coverLetterPrompt(vacancy, profile),
265
+ allowedTools: ['WebFetch'],
266
+ model: db.settings.model,
267
+ signal,
268
+ onEvent: line => jobLog(job, line),
269
+ });
270
+ if (!res.ok) throw new Error(res.error);
271
+ vacancy.coverLetter = res.result.trim();
272
+ if (vacancy.status === 'new') vacancy.status = 'prepared';
273
+ save(db);
274
+ logCost(`Письмо: ${vacancy.company}`, res.costUsd); // в кошельке письма тоже по токенам
275
+ jobLog(job, '✓ сопроводительное письмо готово');
276
+ return { costUsd: res.costUsd };
277
+ });
278
+ }
279
+
280
+ function startAutoApply(vacancy) {
281
+ const position = db.positions.find(p => p.id === vacancy.positionId);
282
+ const profile = db.profiles.find(p => p.id === position?.profileId) || db.profiles[0];
283
+ return createJob('apply', `Автозаявка: ${vacancy.company} — ${vacancy.vacancy_title}`, async (job, signal) => {
284
+ jobLog(job, 'проверяю браузер (CDP :9222)…');
285
+ if (!(await ensureBrowser())) throw new Error('браузер не поднялся на :9222');
286
+ const res = await runClaude({
287
+ prompt: autoApplyPrompt(vacancy, profile, vacancy.coverLetter, db.settings.autoSubmit),
288
+ withBrowser: true,
289
+ allowedTools: ['WebFetch'],
290
+ model: db.settings.model,
291
+ timeoutMs: 15 * 60_000,
292
+ signal,
293
+ onEvent: line => jobLog(job, line),
294
+ });
295
+ if (!res.ok) throw new Error(res.error);
296
+ const outcome = extractJson(res.result) || { status: 'unknown', details: res.result.slice(0, 1000) };
297
+ vacancy.applyResult = { ...outcome, at: Date.now() };
298
+ if (outcome.status === 'submitted') vacancy.status = 'sent';
299
+ else if (outcome.status === 'ready_for_review') vacancy.status = 'ready';
300
+ save(db);
301
+ logCost(`Автозаявка: ${vacancy.company}`, res.costUsd);
302
+ jobLog(job, `✓ ${outcome.status}: ${String(outcome.details || '').slice(0, 200)}`);
303
+ return outcome;
304
+ });
305
+ }
306
+
307
+ function startLeadsSearch() {
308
+ const exclude = [...new Set([...db.leads.map(l => l.company), ...db.vacancies.map(v => v.company)])];
309
+ return createJob('leads', 'Поиск лидов: свежие инвестраунды', async (job, signal) => {
310
+ const res = await runClaude({
311
+ prompt: leadsSearchPrompt(db.profiles, exclude, db.settings),
312
+ allowedTools: ['WebSearch', 'WebFetch'],
313
+ model: db.settings.model,
314
+ timeoutMs: 15 * 60_000,
315
+ signal,
316
+ onEvent: line => jobLog(job, line),
317
+ });
318
+ if (!res.ok) throw new Error(res.error);
319
+ const items = extractJson(res.result);
320
+ if (!Array.isArray(items)) throw new Error('agent returned no JSON array: ' + res.result.slice(0, 300));
321
+ let added = 0;
322
+ for (const it of items) {
323
+ if (!it?.company) continue;
324
+ if (db.leads.some(l => l.company.toLowerCase() === it.company.toLowerCase())) continue;
325
+ const profile = db.profiles.find(p => p.name === it.fits_profile) || db.profiles[0];
326
+ db.leads.push({ id: uid(), status: 'new', pitch: null, foundAt: Date.now(), profileId: profile?.id, ...it });
327
+ added++;
328
+ }
329
+ save(db);
330
+ logCost('Поиск лидов', res.costUsd);
331
+ jobLog(job, `✓ найдено ${items.length}, добавлено новых: ${added}`);
332
+ return { found: items.length, added, costUsd: res.costUsd };
333
+ });
334
+ }
335
+
336
+ function startPitch(lead) {
337
+ const profile = db.profiles.find(p => p.id === lead.profileId) || db.profiles[0];
338
+ return createJob('pitch', `Питч: ${lead.company}`, async (job, signal) => {
339
+ const res = await runClaude({
340
+ prompt: pitchPrompt(lead, profile),
341
+ allowedTools: ['WebFetch'],
342
+ model: db.settings.model,
343
+ signal,
344
+ onEvent: line => jobLog(job, line),
345
+ });
346
+ if (!res.ok) throw new Error(res.error);
347
+ lead.pitch = res.result.trim();
348
+ if (lead.status === 'new') lead.status = 'ready';
349
+ save(db);
350
+ logCost(`Питч: ${lead.company}`, res.costUsd);
351
+ jobLog(job, '✓ питч готов');
352
+ return { costUsd: res.costUsd };
353
+ });
354
+ }
355
+
356
+ /**
357
+ * Онбординг одной кнопкой: импорт резюме (LinkedIn или текст) → позиции из
358
+ * каталога (выбранные или автоподбор по резюме) → автозапуск поиска на
359
+ * сколько хватит кредитов. Любая конфигурация приводит к работающему поиску.
360
+ */
361
+ function startOnboard(profile, { linkedin, resumeText, roles = [], seniority = 'Senior', engagement = 'both' }) {
362
+ return createJob('import', `Новый кандидат: ${profile.name}`, async (job, signal) => {
363
+ // 1. резюме
364
+ let importCost = 0;
365
+ if (resumeText?.trim()) {
366
+ jobLog(job, 'структурирую вставленное резюме…');
367
+ const res = await runClaude({ prompt: importResumePrompt(resumeText), model: db.settings.model, signal, onEvent: l => jobLog(job, l) });
368
+ if (!res.ok) throw new Error(res.error);
369
+ profile.resume = res.result.trim();
370
+ importCost = res.costUsd || 0;
371
+ } else if (linkedin) {
372
+ jobLog(job, 'проверяю браузер (CDP :9222)…');
373
+ if (!(await ensureBrowser())) throw new Error('браузер не поднялся на :9222');
374
+ const rawPath = resolve(DATA_DIR, `linkedin-${profile.id}-raw.txt`);
375
+ jobLog(job, `читаю профиль ${linkedin}…`);
376
+ await new Promise((res2, rej) => {
377
+ const child = spawn(process.execPath, [resolve(ROOT, 'scripts/extract-linkedin.mjs'), linkedin, rawPath], { cwd: ROOT });
378
+ child.stderr.on('data', d => jobLog(job, String(d).trim()));
379
+ child.on('close', code => (code === 0 ? res2() : rej(new Error('extract-linkedin failed'))));
380
+ });
381
+ jobLog(job, 'структурирую резюме через Claude…');
382
+ const res = await runClaude({ prompt: importResumePrompt(readFileSync(rawPath, 'utf8')), model: db.settings.model, signal, onEvent: l => jobLog(job, l) });
383
+ if (!res.ok) throw new Error(res.error);
384
+ profile.resume = res.result.trim();
385
+ profile.linkedin = linkedin;
386
+ importCost = res.costUsd || 0;
387
+ }
388
+ save(db);
389
+ // онбординговое структурирование резюме — тоже токены (для кошелька)
390
+ if (importCost) logCost(`Онбординг: ${profile.name}`, importCost);
391
+
392
+ // 2. позиции из каталога (или автоподбор по резюме)
393
+ let roleIds = roles.filter(id => ROLE_CATALOG.some(r => r.id === id));
394
+ if (!roleIds.length) {
395
+ roleIds = detectRoles(profile.resume);
396
+ jobLog(job, roleIds.length ? `роли подобраны по резюме: ${roleIds.join(', ')}` : 'роли по резюме не распознаны');
397
+ }
398
+ if (!roleIds.length) roleIds = ['fullstack-js']; // запасной вариант: поиск должен работать всегда
399
+ const created = [];
400
+ const maxRoles = plan().maxRoles; // role cap applies to auto-onboarding too
401
+ for (const id of roleIds) {
402
+ if (isFinite(maxRoles) && db.positions.filter(p => p.profileId === profile.id).length >= maxRoles) break;
403
+ const role = ROLE_CATALOG.find(r => r.id === id);
404
+ const title = `${seniority} ${role.title}`.trim();
405
+ if (db.positions.some(p => p.profileId === profile.id && p.title === title)) continue;
406
+ const pos = { id: uid(), title, keywords: role.keywords, engagement, profileId: profile.id, selected: true };
407
+ db.positions.push(pos);
408
+ created.push(pos);
409
+ }
410
+ save(db);
411
+ jobLog(job, `создано позиций: ${created.length}`);
412
+
413
+ // 3. автопоиск по всем созданным позициям (кредитов нет — лимит только на профили)
414
+ const run = created.length ? createSearchRun(created) : null;
415
+ created.forEach(p => startSearch(p, run));
416
+ if (created.length) jobLog(job, `запущен поиск по ${created.length} позициям`);
417
+ return { resumeChars: profile.resume.length, positions: created.length, searches: created.length };
418
+ });
419
+ }
420
+
421
+ function startLinkedinImport(profile, url) {
422
+ return createJob('import', `Импорт LinkedIn: ${profile.name}`, async (job, signal) => {
423
+ jobLog(job, 'проверяю браузер (CDP :9222)…');
424
+ if (!(await ensureBrowser())) throw new Error('браузер не поднялся на :9222');
425
+ const rawPath = resolve(DATA_DIR, `linkedin-${profile.id}-raw.txt`);
426
+ jobLog(job, `читаю профиль ${url}…`);
427
+ await new Promise((res2, rej) => {
428
+ const child = spawn(process.execPath, [resolve(ROOT, 'scripts/extract-linkedin.mjs'), url, rawPath], { cwd: ROOT });
429
+ child.stderr.on('data', d => jobLog(job, String(d).trim()));
430
+ child.on('close', code => (code === 0 ? res2() : rej(new Error('extract-linkedin failed'))));
431
+ });
432
+ jobLog(job, 'структурирую резюме через Claude…');
433
+ const raw = readFileSync(rawPath, 'utf8');
434
+ const res = await runClaude({
435
+ prompt: importResumePrompt(raw),
436
+ model: db.settings.model,
437
+ signal,
438
+ onEvent: line => jobLog(job, line),
439
+ });
440
+ if (!res.ok) throw new Error(res.error);
441
+ profile.resume = res.result.trim();
442
+ profile.linkedin = url;
443
+ save(db);
444
+ logCost(`Импорт резюме: ${profile.name}`, res.costUsd);
445
+ jobLog(job, `✓ резюме импортировано (${profile.resume.length} символов)`);
446
+ return { chars: profile.resume.length };
447
+ });
448
+ }
449
+
450
+ // ── http plumbing ──────────────────────────────────────────────────────────
451
+
452
+ const readBody = req => new Promise((res, rej) => {
453
+ let data = '';
454
+ req.on('data', c => { data += c; if (data.length > 2e6) req.destroy(); });
455
+ req.on('end', () => { try { res(data ? JSON.parse(data) : {}); } catch (e) { rej(e); } });
456
+ req.on('error', rej);
457
+ });
458
+
459
+ const json = (res, code, obj) => {
460
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
461
+ res.end(JSON.stringify(obj));
462
+ };
463
+
464
+ /* Company-logo resolver for the card avatars. The DuckDuckGo favicon service
465
+ answers HTTP 404 for a domain it has no icon for — but with a generic grey
466
+ chevron PNG in the body, which a browser <img> would happily DECODE and show.
467
+ So we resolve it HERE, where the status code is visible: 404 upstream (or that
468
+ known placeholder body) → 404 to the client, whose <img> then errors out and
469
+ the Lucide role glyph shows instead. Real icons stream through, cached in
470
+ memory so a re-render (or the same company twice) costs nothing. */
471
+ const LOGO_CACHE = new Map(); // domain → { type, buf } | null (null = no logo)
472
+ const LOGO_PLACEHOLDER_LEN = 1478; // DDG's "no favicon" chevron — a fixed asset
473
+ const DOMAIN_RE = /^[a-z0-9.-]{1,253}$/;
474
+ async function resolveLogo(domain) {
475
+ if (LOGO_CACHE.has(domain)) return LOGO_CACHE.get(domain);
476
+ let result = null;
477
+ try {
478
+ const r = await fetch(`https://icons.duckduckgo.com/ip3/${domain}.ico`, {
479
+ signal: AbortSignal.timeout(4000),
480
+ });
481
+ const type = r.headers.get('content-type') || '';
482
+ if (r.ok && type.startsWith('image/')) {
483
+ const buf = Buffer.from(await r.arrayBuffer());
484
+ // a live-but-iconless domain can still 200 with the placeholder body
485
+ if (buf.length && buf.length !== LOGO_PLACEHOLDER_LEN) result = { type, buf };
486
+ }
487
+ } catch { /* upstream down / timeout → treat as "no logo", fall back to glyph */ }
488
+ if (LOGO_CACHE.size > 2000) LOGO_CACHE.clear(); // cheap unbounded-growth guard
489
+ LOGO_CACHE.set(domain, result);
490
+ return result;
491
+ }
492
+
493
+ let browserAlive = false;
494
+ async function probeBrowser() {
495
+ try {
496
+ const r = await fetch('http://localhost:9222/json/version', { signal: AbortSignal.timeout(1000) });
497
+ browserAlive = r.ok;
498
+ } catch { browserAlive = false; }
499
+ }
500
+ probeBrowser();
501
+ setInterval(probeBrowser, 15_000).unref();
502
+
503
+ // UI origins allowed to call this local backend cross-origin: the Nx dev
504
+ // servers AND the deployed studio (its static build fetches http://localhost:5544
505
+ // directly — browsers treat localhost as a secure origin, so an https page may).
506
+ const UI_ORIGINS = new Set([
507
+ 'https://studio.qiksy.app',
508
+ 'https://qiksy.app',
509
+ ...Array.from({ length: 9 }, (_, i) => `http://localhost:${4300 + i}`),
510
+ 'http://localhost:4200',
511
+ 'http://127.0.0.1:4200',
512
+ ]);
513
+
514
+ /* The request handler is EXPORTED so the unified qiksy-engine can mount Work Finder as a
515
+ module (it rewrites /api/wf/… → /api/… and delegates here). Run directly (dev/launchd),
516
+ the guard at the bottom still binds its own port, so the standalone backend is unchanged. */
517
+ export const requestHandler = async (req, res) => {
518
+ const url = new URL(req.url, 'http://x');
519
+ const path = url.pathname;
520
+ const seg = path.split('/').filter(Boolean); // e.g. ['api','vacancies',':id','prepare']
521
+
522
+ // CORS for the allowed UI origins (echo the origin, not *, so it stays a list).
523
+ const origin = req.headers.origin;
524
+ if (origin && UI_ORIGINS.has(origin)) {
525
+ res.setHeader('Access-Control-Allow-Origin', origin);
526
+ res.setHeader('Vary', 'Origin');
527
+ if (req.method === 'OPTIONS') {
528
+ // Chrome Private Network Access: public qiksy.app → localhost is blocked unless the
529
+ // preflight carries this header. See the note in client-finder-backend.
530
+ if (req.headers['access-control-request-private-network'] === 'true') {
531
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
532
+ }
533
+ res.writeHead(204, {
534
+ 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
535
+ 'Access-Control-Allow-Headers': 'Content-Type',
536
+ 'Access-Control-Max-Age': '86400',
537
+ });
538
+ res.end();
539
+ return;
540
+ }
541
+ }
542
+
543
+ try {
544
+ if (!path.startsWith('/api')) {
545
+ const file = path === '/' ? 'index.html' : path.slice(1);
546
+ const full = resolve(PUBLIC_DIR, file);
547
+ if (full.startsWith(PUBLIC_DIR) && existsSync(full)) {
548
+ res.writeHead(200, { 'Content-Type': MIME[extname(full)] || 'application/octet-stream' });
549
+ res.end(readFileSync(full));
550
+ } else {
551
+ res.writeHead(404); res.end('not found');
552
+ }
553
+ return;
554
+ }
555
+
556
+ // ── state ──
557
+ /* Start Anthropic's own sign-in on this machine and hand back ITS link, so the
558
+ panel can offer "open" and "copy" instead of firing a browser off-screen. We
559
+ never build a "connect with Claude" of our own — this launches theirs, and the
560
+ panel polls /api/engine until it turns green. */
561
+ if (req.method === 'POST' && path === '/api/engine/login') {
562
+ return json(res, 200, await startLogin());
563
+ }
564
+
565
+ /* The callback page shows a code instead of redirecting when it cannot reach the
566
+ CLI's loopback (another machine, or a browser that won't open localhost). This
567
+ is where that code goes. */
568
+ if (req.method === 'POST' && path === '/api/engine/login/code') {
569
+ const { code } = await readBody(req);
570
+ return json(res, 200, submitLoginCode(code));
571
+ }
572
+
573
+ /* Can this machine run the agent at all? The gate asks BEFORE demanding a key,
574
+ because the CLI may already be logged in with a Claude subscription — in which
575
+ case a key is not just optional, it is irrelevant. ?force=1 re-probes after the
576
+ user has gone and logged in. */
577
+ if (req.method === 'GET' && path === '/api/engine') {
578
+ const force = /[?&]force=1/.test(req.url || '');
579
+ return json(res, 200, await probeEngine({ force }));
580
+ }
581
+
582
+ /* Card avatar logo — proxies + filters the DDG favicon so logo-less companies
583
+ get a clean 404 (→ the UI shows a Lucide role glyph, not a grey placeholder). */
584
+ if (req.method === 'GET' && path === '/api/logo') {
585
+ const d = (url.searchParams.get('d') || '').toLowerCase();
586
+ if (!DOMAIN_RE.test(d) || !d.includes('.')) { res.writeHead(404); res.end(); return; }
587
+ const logo = await resolveLogo(d);
588
+ if (!logo) { res.writeHead(404); res.end(); return; }
589
+ res.writeHead(200, { 'Content-Type': logo.type, 'Cache-Control': 'public, max-age=604800' });
590
+ res.end(logo.buf);
591
+ return;
592
+ }
593
+
594
+ /* LIVE STREAM (SSE). Pushes every job log line, status change and vacancy found the
595
+ instant it happens — so the live-agent theatre shows the agent working without the
596
+ 2.5 s poll lag or the last-12-log-line truncation. The /api/state poll stays the
597
+ source of truth; this is a live overlay. If the EventSource can't connect the
598
+ client just keeps polling, so nothing breaks. Never json()/end() here — the socket
599
+ stays open until the client disconnects. */
600
+ if (req.method === 'GET' && path === '/api/stream') {
601
+ res.writeHead(200, {
602
+ 'Content-Type': 'text/event-stream; charset=utf-8',
603
+ 'Cache-Control': 'no-cache, no-transform',
604
+ Connection: 'keep-alive',
605
+ 'X-Accel-Buffering': 'no', // don't let a proxy buffer the stream
606
+ });
607
+ res.write('retry: 3000\n\n');
608
+ // Sync frame: any searches already running when a tab (re)connects mid-run.
609
+ const live = listJobs().filter(j => j.status === 'running' || j.status === 'queued')
610
+ .map(j => ({ id: j.id, type: j.type, label: j.label, status: j.status }));
611
+ res.write(`data: ${JSON.stringify({ kind: 'hello', jobs: live, at: Date.now() })}\n\n`);
612
+ const onEvent = ev => {
613
+ // A stalled-but-connected reader must not buffer unbounded: past ~1 MB queued,
614
+ // drop frames until it drains (the /api/state poll still reconciles the truth).
615
+ if (res.writableLength > 1_000_000) return;
616
+ try { res.write(`data: ${JSON.stringify(ev)}\n\n`); } catch { /* socket gone */ }
617
+ };
618
+ jobEvents.on('event', onEvent);
619
+ const ka = setInterval(() => { try { res.write(': ping\n\n'); } catch { /* gone */ } }, 25_000);
620
+ ka.unref?.();
621
+ const cleanup = () => { clearInterval(ka); jobEvents.off('event', onEvent); };
622
+ req.on('close', cleanup);
623
+ req.on('error', cleanup);
624
+ res.on('error', cleanup);
625
+ return;
626
+ }
627
+
628
+ if (req.method === 'GET' && path === '/api/state') {
629
+ return json(res, 200, {
630
+ ...db, browserAlive, jobs: listJobs(), billing: billingInfo(),
631
+ catalog: ROLE_CATALOG.map(({ id, group, title, keywords }) => ({ id, group, title, keywords })),
632
+ seniority: SENIORITY, locations: LOCATIONS,
633
+ });
634
+ }
635
+ /* SEARCH HISTORY — grouped by day, per candidate.
636
+ Grouping happens here, not in the browser, because the day boundary depends on
637
+ the machine's own clock and the UI should not have to agree with it separately.
638
+ Labels are NOT localised here: the API returns `daysAgo`, the interface says
639
+ "today" / "yesterday" in the user's language. */
640
+ if (req.method === 'GET' && path === '/api/history') {
641
+ const startOfDay = t => { const d = new Date(t); d.setHours(0, 0, 0, 0); return d.getTime(); };
642
+ const today = startOfDay(Date.now());
643
+ const nameOf = id => db.profiles.find(p => p.id === id)?.name || '';
644
+ const byDay = new Map();
645
+ for (const r of [...(db.searches || [])].sort((a, b) => b.at - a.at)) {
646
+ const day = startOfDay(r.at);
647
+ if (!byDay.has(day)) byDay.set(day, []);
648
+ // A run's findings are counted LIVE, not from the stored number: a vacancy
649
+ // deleted since (or purged) must not still be advertised as findable.
650
+ const alive = (r.vacancyIds || []).filter(id => db.vacancies.some(v => v.id === id));
651
+ byDay.get(day).push({
652
+ id: r.id,
653
+ at: r.at,
654
+ titles: r.titles || [],
655
+ profiles: (r.profileIds || []).map(id => ({ id, name: nameOf(id) })).filter(p => p.name),
656
+ found: alive.length,
657
+ vacancyIds: alive,
658
+ });
659
+ }
660
+ return json(res, 200, {
661
+ keptDays: db.settings.historyDays ?? 30,
662
+ days: [...byDay.entries()].map(([day, runs]) => ({
663
+ day,
664
+ daysAgo: Math.round((today - day) / 86_400_000),
665
+ runs,
666
+ found: runs.reduce((n, r) => n + r.found, 0),
667
+ })),
668
+ });
669
+ }
670
+
671
+ if (req.method === 'GET' && path === '/api/jobs') {
672
+ return json(res, 200, listJobs());
673
+ }
674
+ if (req.method === 'POST' && seg[1] === 'jobs' && seg[2] && seg[3] === 'cancel') {
675
+ return json(res, 200, { ok: cancelJob(seg[2]) });
676
+ }
677
+ if (req.method === 'GET' && seg[1] === 'jobs' && seg[2]) {
678
+ const j = getJob(seg[2]);
679
+ return j ? json(res, 200, j) : json(res, 404, { error: 'no such job' });
680
+ }
681
+
682
+ // ── settings ──
683
+ if (req.method === 'POST' && path === '/api/settings') {
684
+ const body = await readBody(req);
685
+ // Платные планы включаются лицензией, не руками. `dev` (безлимит) — тоже не
686
+ // руками: это лазейка владельца, и она открыта только при старте с WF_DEV=1.
687
+ // Без флага из настроек доступен один free — иначе покупатель в один клик
688
+ // выставил бы «Dev — без лимитов» и обошёл лимит кандидатов.
689
+ const selfSettable = DEV_UNLOCKED ? SELF_SETTABLE_PLANS : SELF_SETTABLE_PLANS.filter((p) => p !== 'dev');
690
+ if ('plan' in body && !selfSettable.includes(body.plan)) {
691
+ delete body.plan;
692
+ }
693
+ // license/licenseInfo меняются только через /api/license — не даём их перезаписать.
694
+ delete body.license;
695
+ delete body.licenseInfo;
696
+ Object.assign(db.settings, body);
697
+ save(db);
698
+ return json(res, 200, db.settings);
699
+ }
700
+
701
+ // ── watch / мониторинг (Pro): включить/выключить + частота ──
702
+ if (req.method === 'POST' && path === '/api/watch') {
703
+ if (!plan().watch) return json(res, 402, { error: 'Мониторинг доступен на Pro.', code: 'watch_pro' });
704
+ const b = await readBody(req);
705
+ const cur = db.settings.watch || { lastRunAt: 0 };
706
+ db.settings.watch = {
707
+ enabled: b.enabled !== undefined ? !!b.enabled : !!cur.enabled,
708
+ everyHours: b.everyHours !== undefined ? Math.max(1, Math.min(168, Number(b.everyHours) || 6)) : (cur.everyHours || 6),
709
+ lastRunAt: cur.lastRunAt || 0,
710
+ };
711
+ save(db);
712
+ return json(res, 200, db.settings.watch);
713
+ }
714
+
715
+ // ── license: ввести/сменить/убрать ключ и сразу проверить у воркера ──
716
+ if (req.method === 'POST' && path === '/api/license') {
717
+ const { key } = await readBody(req);
718
+ db.settings.license = typeof key === 'string' ? key.trim() : '';
719
+ db.settings.licenseInfo = null;
720
+ save(db);
721
+ await refreshLicense();
722
+ return json(res, 200, { billing: billingInfo() });
723
+ }
724
+
725
+ // ── profiles ──
726
+ if (req.method === 'POST' && path === '/api/profiles') {
727
+ const b = await readBody(req);
728
+ if (b.id) {
729
+ const p = db.profiles.find(x => x.id === b.id);
730
+ if (!p) return json(res, 404, { error: 'no such profile' });
731
+ Object.assign(p, { name: b.name ?? p.name, email: b.email ?? p.email, linkedin: b.linkedin ?? p.linkedin, resume: b.resume ?? p.resume, location: b.location ?? p.location ?? '' });
732
+ save(db);
733
+ return json(res, 200, p);
734
+ }
735
+ if (db.profiles.length >= maxProfilesEff()) {
736
+ return json(res, 402, { error: `Тариф «${plan().name}»: максимум ${maxProfilesEff()} кандидат(а). Больше кандидатов — план Team (места) в настройках.` });
737
+ }
738
+ const p = { id: uid(), name: b.name || 'Новый профиль', email: b.email || '', linkedin: b.linkedin || '', resume: b.resume || '', location: b.location || '' };
739
+ db.profiles.push(p);
740
+ save(db);
741
+ return json(res, 200, p);
742
+ }
743
+ if (req.method === 'DELETE' && seg[1] === 'profiles' && seg[2]) {
744
+ // Каскад: роли кандидата и их нетронутые находки уходят вместе с ним, иначе
745
+ // остаются сиротами в db.json навсегда и всплывают в списке. Найденное, над чем
746
+ // юзер работал, сохраняем.
747
+ const posIds = new Set(db.positions.filter(p => p.profileId === seg[2]).map(p => p.id));
748
+ db.positions.forEach(p => { if (posIds.has(p.id)) cancelActiveByLabel(`Поиск: ${p.title}`); });
749
+ db.vacancies = db.vacancies.filter(v => !posIds.has(v.positionId) || isTouched(v));
750
+ db.positions = db.positions.filter(p => p.profileId !== seg[2]);
751
+ db.profiles = db.profiles.filter(p => p.id !== seg[2]);
752
+ save(db);
753
+ return json(res, 200, { ok: true });
754
+ }
755
+ if (req.method === 'POST' && seg[1] === 'profiles' && seg[3] === 'import-linkedin') {
756
+ const p = db.profiles.find(x => x.id === seg[2]);
757
+ if (!p) return json(res, 404, { error: 'no such profile' });
758
+ const { url: liUrl } = await readBody(req);
759
+ const liCanon = normalizeLinkedin(liUrl);
760
+ if (!liCanon) return json(res, 400, { error: 'нужна ссылка на профиль вида linkedin.com/in/ваше-имя' });
761
+ return json(res, 200, { jobId: startLinkedinImport(p, liUrl).id });
762
+ }
763
+
764
+ // ── onboarding: кандидат одной кнопкой ──
765
+ if (req.method === 'POST' && path === '/api/onboard') {
766
+ const b = await readBody(req);
767
+ if (!b.name?.trim()) return json(res, 400, { error: 'нужно имя кандидата' });
768
+ if (!b.linkedin?.trim() && !b.resumeText?.trim()) return json(res, 400, { error: 'нужен LinkedIn URL или текст резюме' });
769
+ if (b.linkedin) {
770
+ const canon = normalizeLinkedin(b.linkedin);
771
+ if (!canon) return json(res, 400, { error: 'нужна ссылка на профиль вида linkedin.com/in/ваше-имя' });
772
+ b.linkedin = canon; // store one shape, whatever the user pasted
773
+ }
774
+ if (db.profiles.length >= maxProfilesEff()) {
775
+ return json(res, 402, { error: `Тариф «${plan().name}»: максимум ${maxProfilesEff()} кандидат(а). Больше кандидатов — план Team (места) в настройках.` });
776
+ }
777
+ const profile = { id: uid(), name: b.name.trim(), email: b.email || '', linkedin: b.linkedin?.trim() || '', location: b.location || 'Ukraine', resume: '' };
778
+ db.profiles.push(profile);
779
+ save(db);
780
+ return json(res, 200, { profileId: profile.id, jobId: startOnboard(profile, b).id });
781
+ }
782
+
783
+ // ── positions ──
784
+ if (req.method === 'POST' && path === '/api/positions') {
785
+ const b = await readBody(req);
786
+ if (!b.title) return json(res, 400, { error: 'title required' });
787
+ // by-role search carries NO candidate → keep profileId null (résumé-less); otherwise
788
+ // default to the sent profile or the first candidate.
789
+ const targetProfileId = b.byRole ? null : (b.profileId || db.profiles[0]?.id);
790
+ // ROLE CAP (owner 2026-07-25): roles are metered PER candidate (by-role null is its
791
+ // own bucket). Without this one padded candidate could hunt 30 roles = the work of
792
+ // ten candidates for free. Server-enforced — the client cap is bypassable.
793
+ const inBucket = db.positions.filter((p) => p.profileId === targetProfileId).length;
794
+ const maxRoles = plan().maxRoles;
795
+ if (isFinite(maxRoles) && inBucket >= maxRoles) {
796
+ return json(res, 402, { error: `Тариф «${plan().name}»: максимум ${maxRoles} ${maxRoles === 1 ? 'роль' : 'ролей'} на кандидата. Разные профессии — это разные кандидаты (Team).`, code: 'role_limit', maxRoles });
797
+ }
798
+ const pos = { id: uid(), title: b.title, keywords: b.keywords || '', engagement: b.engagement || 'both', profileId: targetProfileId, selected: true };
799
+ db.positions.push(pos);
800
+ save(db);
801
+ return json(res, 200, pos);
802
+ }
803
+ // roles come FROM the candidate's résumé — derive them (detectRoles) for a profile
804
+ // that has a résumé but no roles yet (owner model: a role is a property of the candidate)
805
+ if (req.method === 'POST' && seg[1] === 'profiles' && seg[2] && seg[3] === 'detect-roles') {
806
+ const profile = db.profiles.find(p => p.id === seg[2]);
807
+ if (!profile) return json(res, 404, { error: 'no such profile' });
808
+ if (!profile.resume?.trim()) return json(res, 400, { error: 'у кандидата нет резюме — сначала соберите профиль' });
809
+ let roleIds = detectRoles(profile.resume);
810
+ if (!roleIds.length) roleIds = ['fullstack-js'];
811
+ const created = [];
812
+ // Respect the role cap: a padded résumé must not slip past the per-candidate limit
813
+ // by having detectRoles mint the extra positions. Count what this candidate already
814
+ // has and stop at plan().maxRoles.
815
+ const maxRoles = plan().maxRoles;
816
+ for (const id of roleIds) {
817
+ const already = db.positions.filter(p => p.profileId === profile.id).length;
818
+ if (isFinite(maxRoles) && already >= maxRoles) break;
819
+ const role = ROLE_CATALOG.find(r => r.id === id);
820
+ if (!role) continue;
821
+ if (db.positions.some(p => p.profileId === profile.id && p.title === role.title)) continue;
822
+ const pos = { id: uid(), title: role.title, keywords: role.keywords, engagement: 'both', profileId: profile.id, selected: true };
823
+ db.positions.push(pos);
824
+ created.push(pos);
825
+ }
826
+ save(db);
827
+ return json(res, 200, { created: created.length, positions: created, maxRoles: isFinite(maxRoles) ? maxRoles : null });
828
+ }
829
+ // «Tell about yourself» (voice/text) → a structured professional profile + gaps.
830
+ // Runs on the machine's Claude sign-in (no key), hard-scoped to the profession.
831
+ if (req.method === 'POST' && path === '/api/builder/from-story') {
832
+ const b = await readBody(req);
833
+ const story = (b.story || '').trim();
834
+ if (story.length < 12) return json(res, 400, { error: 'расскажите чуть подробнее о себе' });
835
+ const r = await runClaude({ prompt: buildFromStoryPrompt(story, b.lang || 'en'), model: db.settings.model });
836
+ if (!r.ok) return json(res, 502, { error: r.error || 'claude failed' });
837
+ const parsed = extractJson(r.result);
838
+ if (!parsed) return json(res, 502, { error: 'не удалось разобрать ответ' });
839
+ logCost('Сбор профиля из рассказа', r.costUsd);
840
+ return json(res, 200, { profile: parsed, gaps: Array.isArray(parsed.gaps) ? parsed.gaps : [], costUsd: r.costUsd });
841
+ }
842
+ if (req.method === 'PATCH' && seg[1] === 'positions' && seg[2]) {
843
+ const p = db.positions.find(x => x.id === seg[2]);
844
+ if (!p) return json(res, 404, { error: 'no such position' });
845
+ const b = await readBody(req);
846
+ for (const k of ['title', 'keywords', 'engagement', 'profileId']) if (b[k] !== undefined) p[k] = b[k];
847
+ if (b.selected !== undefined) {
848
+ p.selected = !!b.selected;
849
+ // выключили лейбл — прерываем его текущий поиск
850
+ if (!p.selected) cancelActiveByLabel(`Поиск: ${p.title}`);
851
+ }
852
+ save(db);
853
+ return json(res, 200, p);
854
+ }
855
+ if (req.method === 'DELETE' && seg[1] === 'positions' && seg[2]) {
856
+ const pos = db.positions.find(p => p.id === seg[2]);
857
+ if (pos) cancelActiveByLabel(`Поиск: ${pos.title}`);
858
+ // Роль уходит вместе со своими нетронутыми находками (иначе сироты всплывают,
859
+ // когда не выбрана ни одна роль). То, над чем работали, остаётся.
860
+ db.vacancies = db.vacancies.filter(v => v.positionId !== seg[2] || isTouched(v));
861
+ db.positions = db.positions.filter(p => p.id !== seg[2]);
862
+ save(db);
863
+ return json(res, 200, { ok: true });
864
+ }
865
+
866
+ // ── ревизия ссылок по всей базе (без токенов) ──
867
+ if (req.method === 'POST' && path === '/api/validate-links') {
868
+ if (listJobs().some(j => j.type === 'validate' && (j.status === 'running' || j.status === 'queued'))) {
869
+ return json(res, 400, { error: 'проверка ссылок уже идёт' });
870
+ }
871
+ const job = createJob('validate', 'Проверка ссылок в базе', async j => {
872
+ const targets = db.vacancies.filter(v => ['new', 'prepared', 'ready'].includes(v.status) && (v.vacancy_url || v.careers_url));
873
+ jobLog(j, `проверяю ${targets.length} вакансий…`);
874
+ const results = await checkMany(targets.map(v => v.vacancy_url || v.careers_url));
875
+ let deadCount = 0;
876
+ for (const v of targets) {
877
+ const chk = results.get(v.vacancy_url || v.careers_url);
878
+ if (chk && !chk.ok) {
879
+ v.status = 'hidden';
880
+ v.dead_reason = chk.reason;
881
+ deadCount++;
882
+ jobLog(j, `✂ ${v.company} — ${v.vacancy_title}: ${chk.reason}`);
883
+ }
884
+ }
885
+ save(db);
886
+ jobLog(j, `✓ проверено ${targets.length}, скрыто мёртвых: ${deadCount}`);
887
+ return { checked: targets.length, dead: deadCount };
888
+ });
889
+ return json(res, 200, { jobId: job.id });
890
+ }
891
+
892
+ // ── search ──
893
+ if (req.method === 'POST' && path === '/api/search') {
894
+ const { positionId, positionIds } = await readBody(req);
895
+ let targets;
896
+ if (positionIds?.length) targets = db.positions.filter(p => positionIds.includes(p.id));
897
+ else if (positionId) targets = db.positions.filter(p => p.id === positionId);
898
+ else targets = db.positions.filter(p => p.selected); // multi-choice labels in the UI
899
+ if (!targets.length) return json(res, 400, { error: 'выберите хотя бы одну позицию (кликните по лейблу)' });
900
+ // guard: не ставить дубль, если по этой позиции уже идёт/стоит в очереди поиск
901
+ const activeLabels = new Set(listJobs()
902
+ .filter(j => j.type === 'search' && (j.status === 'running' || j.status === 'queued'))
903
+ .map(j => j.label));
904
+ const fresh = targets.filter(p => !activeLabels.has(`Поиск: ${p.title}`));
905
+ const run = fresh.length ? createSearchRun(fresh) : null;
906
+ return json(res, 200, { jobIds: fresh.map(p => startSearch(p, run).id), skipped: targets.length - fresh.length, runId: run?.id });
907
+ }
908
+
909
+ // ── leads ──
910
+ if (req.method === 'POST' && path === '/api/leads-search') {
911
+ if (!plan().leads) return json(res, 402, { error: `Поиск лидов доступен на тарифе Pro (сейчас «${plan().name}»).` });
912
+ const running = listJobs().some(j => j.type === 'leads' && (j.status === 'running' || j.status === 'queued'));
913
+ if (running) return json(res, 400, { error: 'поиск лидов уже идёт' });
914
+ return json(res, 200, { jobId: startLeadsSearch().id });
915
+ }
916
+ if (seg[1] === 'leads' && seg[2]) {
917
+ const l = db.leads.find(x => x.id === seg[2]);
918
+ if (!l) return json(res, 404, { error: 'no such lead' });
919
+ if (req.method === 'PATCH') {
920
+ const b = await readBody(req);
921
+ if (b.status) l.status = b.status;
922
+ if (b.pitch !== undefined) l.pitch = b.pitch;
923
+ save(db);
924
+ return json(res, 200, l);
925
+ }
926
+ if (req.method === 'DELETE') {
927
+ db.leads = db.leads.filter(x => x.id !== seg[2]);
928
+ save(db);
929
+ return json(res, 200, { ok: true });
930
+ }
931
+ if (req.method === 'POST' && seg[3] === 'pitch') return json(res, 200, { jobId: startPitch(l).id });
932
+ }
933
+
934
+ // ── vacancies ──
935
+ if (seg[1] === 'vacancies' && seg[2]) {
936
+ const v = db.vacancies.find(x => x.id === seg[2]);
937
+ if (!v) return json(res, 404, { error: 'no such vacancy' });
938
+ if (req.method === 'PATCH') {
939
+ const b = await readBody(req);
940
+ if (b.status) v.status = b.status;
941
+ if (b.coverLetter !== undefined) v.coverLetter = b.coverLetter;
942
+ if (b.userNote !== undefined) v.userNote = String(b.userNote); // юзерская заметка по вакансии
943
+ // Закреплённые — «вернуться к этой»: ортогонально статусу, поэтому
944
+ // отдельный флаг, а не ещё одно значение status.
945
+ if (b.pinned !== undefined) v.pinned = !!b.pinned;
946
+ save(db);
947
+ return json(res, 200, v);
948
+ }
949
+ if (req.method === 'DELETE') {
950
+ db.vacancies = db.vacancies.filter(x => x.id !== seg[2]);
951
+ save(db);
952
+ return json(res, 200, { ok: true });
953
+ }
954
+ if (req.method === 'POST' && seg[3] === 'prepare') return json(res, 200, { jobId: startCoverLetter(v).id });
955
+ if (req.method === 'POST' && seg[3] === 'autoapply') {
956
+ if (!plan().autoApply) return json(res, 402, { error: `Автозаявки доступны на тарифе Pro (сейчас «${plan().name}»). Письмо и прямая ссылка на форму доступны бесплатно.` });
957
+ if (!v.vacancy_url && !v.careers_url) return json(res, 400, { error: 'у вакансии нет URL' });
958
+ return json(res, 200, { jobId: startAutoApply(v).id });
959
+ }
960
+ }
961
+
962
+ json(res, 404, { error: 'unknown endpoint' });
963
+ } catch (err) {
964
+ json(res, 500, { error: String(err?.message || err) });
965
+ }
966
+ };
967
+
968
+ mkdirSync(DATA_DIR, { recursive: true });
969
+ // Standalone (dev / launchd) binds its own port. The unified qiksy-engine sets QE_EMBEDDED
970
+ // before importing this file, so an import yields requestHandler WITHOUT a second listener
971
+ // on :5544. Env, not an argv-path check — launchd invokes the script by a path that doesn't
972
+ // match import.meta.url, which left the backend running but never listening.
973
+ if (!process.env.QE_EMBEDDED) {
974
+ createServer(requestHandler).listen(PORT, () => {
975
+ console.log(`Work Finder → http://localhost:${PORT}`);
976
+ });
977
+ }
978
+
979
+ // Проверяем лицензию на старте и раз в 6 часов (best-effort; офлайн не роняет кэш).
980
+ if (db.settings.license) refreshLicense().catch(() => {});
981
+ setInterval(() => { if (db.settings.license) refreshLicense().catch(() => {}); }, RECHECK_MS).unref();
982
+
983
+ // ── WATCH / мониторинг (Pro) ─────────────────────────────────────────────────
984
+ // Периодически сам перезапускает поиск по активным ролям, чтобы НОВОЕ всплывало без
985
+ // ручного клика — это и есть «находим раньше рынка». Дедуп уже встроен (поиск исключает
986
+ // уже найденные компании), так что watch-прогон просто досыпает свежие находки. Local-
987
+ // first: крутится на Claude пользователя, нам это ничего не стоит. Гейт — только Pro.
988
+ const WATCH_TICK_MS = 10 * 60_000; // проверяем «пора ли» каждые 10 минут
989
+ function watchTick() {
990
+ try {
991
+ const w = db.settings.watch;
992
+ if (!w?.enabled || !plan().watch) return; // выключено или не Pro
993
+ const everyMs = Math.max(1, Number(w.everyHours) || 6) * 3600_000;
994
+ if (Date.now() - (w.lastRunAt || 0) < everyMs) return; // ещё рано
995
+ // не наслаивать: если поиск уже идёт — пропускаем этот тик
996
+ const busy = listJobs().some((j) => j.type === 'search' && (j.status === 'running' || j.status === 'queued'));
997
+ if (busy) return;
998
+ const targets = db.positions.filter((p) => p.selected !== false);
999
+ if (!targets.length) return;
1000
+ w.lastRunAt = Date.now();
1001
+ save(db);
1002
+ const run = createSearchRun(targets);
1003
+ targets.forEach((p) => startSearch(p, run));
1004
+ } catch { /* watch — best-effort, никогда не роняем процесс */ }
1005
+ }
1006
+ setInterval(watchTick, WATCH_TICK_MS).unref();