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.
- package/ARCHITECTURE.md +78 -0
- package/INSTALL-PROMPT.md +53 -0
- package/bin/qiksy.mjs +302 -0
- package/package.json +32 -0
- package/tools/client-finder/vendor/README.md +70 -0
- package/tools/client-finder/vendor/find.mjs +121 -0
- package/tools/client-finder/vendor/icp/assist-pl-shops.json +30 -0
- package/tools/client-finder/vendor/icp/assist-ua-shops.json +30 -0
- package/tools/client-finder/vendor/knowledge/assist.md +68 -0
- package/tools/client-finder/vendor/knowledge/custom.md +36 -0
- package/tools/client-finder/vendor/knowledge/multilogin.md +29 -0
- package/tools/client-finder/vendor/knowledge/pay.md +26 -0
- package/tools/client-finder/vendor/knowledge/qa-copilot.md +43 -0
- package/tools/client-finder/vendor/knowledge/travel.md +43 -0
- package/tools/client-finder/vendor/knowledge/work-finder.md +52 -0
- package/tools/client-finder/vendor/lib/access.mjs +108 -0
- package/tools/client-finder/vendor/lib/agent.mjs +204 -0
- package/tools/client-finder/vendor/lib/approver-mcp.mjs +78 -0
- package/tools/client-finder/vendor/lib/browser.mjs +108 -0
- package/tools/client-finder/vendor/lib/catalog.mjs +86 -0
- package/tools/client-finder/vendor/lib/channels.mjs +54 -0
- package/tools/client-finder/vendor/lib/deliver.mjs +89 -0
- package/tools/client-finder/vendor/lib/engine.mjs +283 -0
- package/tools/client-finder/vendor/lib/formats.mjs +108 -0
- package/tools/client-finder/vendor/lib/jobs.mjs +75 -0
- package/tools/client-finder/vendor/lib/knowledge.mjs +26 -0
- package/tools/client-finder/vendor/lib/prompts.mjs +183 -0
- package/tools/client-finder/vendor/lib/report.mjs +75 -0
- package/tools/client-finder/vendor/lib/scope.mjs +162 -0
- package/tools/client-finder/vendor/lib/spawn-claude.mjs +44 -0
- package/tools/client-finder/vendor/lib/verify.mjs +96 -0
- package/tools/client-finder/vendor/package.json +8 -0
- package/tools/client-finder/vendor/server.mjs +390 -0
- package/tools/travel/vendor/README.md +58 -0
- package/tools/travel/vendor/docs/CONTRACT.md +174 -0
- package/tools/travel/vendor/docs/ENGINE-MIGRATION.md +33 -0
- package/tools/travel/vendor/lib/agent.mjs +190 -0
- package/tools/travel/vendor/lib/engine.mjs +283 -0
- package/tools/travel/vendor/lib/jobs.mjs +125 -0
- package/tools/travel/vendor/lib/license.mjs +41 -0
- package/tools/travel/vendor/lib/plans.mjs +28 -0
- package/tools/travel/vendor/lib/prompts.mjs +394 -0
- package/tools/travel/vendor/lib/proposal.mjs +207 -0
- package/tools/travel/vendor/lib/spawn-claude.mjs +44 -0
- package/tools/travel/vendor/lib/store.mjs +75 -0
- package/tools/travel/vendor/package.json +12 -0
- package/tools/travel/vendor/public/app.js +996 -0
- package/tools/travel/vendor/public/index.html +112 -0
- package/tools/travel/vendor/public/styles.css +760 -0
- package/tools/travel/vendor/scripts/approver-mcp.mjs +80 -0
- package/tools/travel/vendor/scripts/launch-browser.mjs +77 -0
- package/tools/travel/vendor/server.mjs +755 -0
- package/tools/work-finder/vendor/.claude/settings.local.json +27 -0
- package/tools/work-finder/vendor/.mcp.json +9 -0
- package/tools/work-finder/vendor/CLAUDE.md +24 -0
- package/tools/work-finder/vendor/README.md +44 -0
- package/tools/work-finder/vendor/bin/qiksy-work-finder.mjs +127 -0
- package/tools/work-finder/vendor/lib/agent.mjs +159 -0
- package/tools/work-finder/vendor/lib/catalog.mjs +41 -0
- package/tools/work-finder/vendor/lib/channels.mjs +73 -0
- package/tools/work-finder/vendor/lib/engine.mjs +283 -0
- package/tools/work-finder/vendor/lib/jobs.mjs +127 -0
- package/tools/work-finder/vendor/lib/license.mjs +60 -0
- package/tools/work-finder/vendor/lib/linkcheck.mjs +72 -0
- package/tools/work-finder/vendor/lib/linkedin.mjs +22 -0
- package/tools/work-finder/vendor/lib/plans.mjs +33 -0
- package/tools/work-finder/vendor/lib/prompts.mjs +217 -0
- package/tools/work-finder/vendor/lib/spawn-claude.mjs +44 -0
- package/tools/work-finder/vendor/lib/store.mjs +125 -0
- package/tools/work-finder/vendor/package.json +41 -0
- package/tools/work-finder/vendor/public/app.js +1863 -0
- package/tools/work-finder/vendor/public/index.html +518 -0
- package/tools/work-finder/vendor/public/styles.css +930 -0
- package/tools/work-finder/vendor/scripts/approver-mcp.mjs +69 -0
- package/tools/work-finder/vendor/scripts/extract-linkedin.mjs +81 -0
- package/tools/work-finder/vendor/scripts/launch-browser.mjs +78 -0
- package/tools/work-finder/vendor/scripts/screenshot.mjs +17 -0
- package/tools/work-finder/vendor/server.mjs +1006 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Can this machine actually run the agent — and how is it paying?
|
|
3
|
+
*
|
|
4
|
+
* The tools are local-first: the work is done by the `claude` CLI on the user's own
|
|
5
|
+
* machine. That CLI authenticates in one of two ways — a Claude subscription it was
|
|
6
|
+
* signed into once, or an Anthropic API key. Both work, and they are BILLED FROM
|
|
7
|
+
* DIFFERENT WALLETS: a claude.ai subscription grants no API credit, and an API key
|
|
8
|
+
* gives no subscription.
|
|
9
|
+
*
|
|
10
|
+
* This matters because the gate used to demand an API key from everyone, and two of
|
|
11
|
+
* the three tools never even forwarded it — they spawn `claude` with the ambient
|
|
12
|
+
* environment. So a Max subscriber with a working setup was sent to buy credits for a
|
|
13
|
+
* credential the engine ignored. The question is not "do you have a key" but "can this
|
|
14
|
+
* machine run it", and only the machine can answer.
|
|
15
|
+
*
|
|
16
|
+
* `claude auth status` answers it for free and instantly — no tokens, no round trip to
|
|
17
|
+
* Anthropic's inference API. It is also the only way to see the SUBSCRIPTION behind an
|
|
18
|
+
* API key: with ANTHROPIC_API_KEY set the CLI reports `apiKeySource` and hides the
|
|
19
|
+
* account, so the probe deliberately runs without it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawnClaude } from './spawn-claude.mjs';
|
|
23
|
+
|
|
24
|
+
const TTL_MS = 60 * 1000;
|
|
25
|
+
|
|
26
|
+
let cached = null;
|
|
27
|
+
let inflight = null;
|
|
28
|
+
|
|
29
|
+
function cleanEnv() {
|
|
30
|
+
const env = { ...process.env };
|
|
31
|
+
// The same strip the agent runner does: a CLI spawned from inside Claude Code
|
|
32
|
+
// otherwise inherits markers that change how it behaves.
|
|
33
|
+
delete env.CLAUDECODE;
|
|
34
|
+
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
35
|
+
// Deliberately removed: the machine's own auth is exactly what we are asking
|
|
36
|
+
// about. With a key in the environment the CLI reports the key and nothing about
|
|
37
|
+
// the subscription underneath it.
|
|
38
|
+
delete env.ANTHROPIC_API_KEY;
|
|
39
|
+
return env;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function run(args, { timeoutMs = 20_000, detached = false } = {}) {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const env = cleanEnv();
|
|
45
|
+
|
|
46
|
+
let out = '';
|
|
47
|
+
let err = '';
|
|
48
|
+
let done = false;
|
|
49
|
+
let child;
|
|
50
|
+
try {
|
|
51
|
+
child = spawnClaude(args, {
|
|
52
|
+
env,
|
|
53
|
+
detached,
|
|
54
|
+
stdio: detached ? 'ignore' : ['ignore', 'pipe', 'pipe'],
|
|
55
|
+
});
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return resolve({ code: null, out, err: e.code === 'ENOENT' ? 'not-installed' : e.message });
|
|
58
|
+
}
|
|
59
|
+
if (detached) {
|
|
60
|
+
child.unref();
|
|
61
|
+
return resolve({ code: 0, out, err });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const timer = setTimeout(() => {
|
|
65
|
+
if (done) return;
|
|
66
|
+
done = true;
|
|
67
|
+
child.kill('SIGKILL');
|
|
68
|
+
resolve({ code: null, out, err: 'timeout' });
|
|
69
|
+
}, timeoutMs);
|
|
70
|
+
|
|
71
|
+
child.stdout.on('data', (d) => (out += d));
|
|
72
|
+
child.stderr.on('data', (d) => (err += d));
|
|
73
|
+
child.on('error', (e) => {
|
|
74
|
+
if (done) return;
|
|
75
|
+
done = true;
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
resolve({ code: null, out, err: e.code === 'ENOENT' ? 'not-installed' : e.message });
|
|
78
|
+
});
|
|
79
|
+
child.on('close', (code) => {
|
|
80
|
+
if (done) return;
|
|
81
|
+
done = true;
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
resolve({ code, out, err });
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parse(out) {
|
|
89
|
+
try {
|
|
90
|
+
const i = out.indexOf('{');
|
|
91
|
+
const j = out.lastIndexOf('}');
|
|
92
|
+
return i >= 0 && j > i ? JSON.parse(out.slice(i, j + 1)) : null;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function measure() {
|
|
99
|
+
const r = await run(['auth', 'status']);
|
|
100
|
+
if (r.err === 'not-installed') {
|
|
101
|
+
return { ok: false, mode: 'none', reason: 'cli-missing', account: null, plan: null };
|
|
102
|
+
}
|
|
103
|
+
const st = parse(r.out);
|
|
104
|
+
if (!st) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
mode: 'none',
|
|
108
|
+
reason: 'cli-broken',
|
|
109
|
+
account: null,
|
|
110
|
+
plan: null,
|
|
111
|
+
detail: (r.err || r.out || '').slice(0, 300),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (!st.loggedIn) {
|
|
115
|
+
return { ok: false, mode: 'none', reason: 'not-authenticated', account: null, plan: null };
|
|
116
|
+
}
|
|
117
|
+
// Signed in with a claude.ai account → the subscription pays, no key needed.
|
|
118
|
+
// Anything else means the CLI is leaning on API billing, which needs credit.
|
|
119
|
+
const bySubscription = st.authMethod === 'claude.ai' && !st.apiKeySource;
|
|
120
|
+
return {
|
|
121
|
+
ok: true,
|
|
122
|
+
mode: bySubscription ? 'subscription' : 'key',
|
|
123
|
+
reason: null,
|
|
124
|
+
account: st.email ?? null,
|
|
125
|
+
plan: st.subscriptionType ?? null,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {{force?: boolean}} [opts]
|
|
131
|
+
* @returns {Promise<{ok:boolean, mode:'subscription'|'key'|'none', reason:string|null,
|
|
132
|
+
* account:string|null, plan:string|null, checkedAt:number}>}
|
|
133
|
+
*/
|
|
134
|
+
export async function probeEngine({ force = false } = {}) {
|
|
135
|
+
if (cached && !force && Date.now() - cached.checkedAt < TTL_MS) return cached;
|
|
136
|
+
// One probe at a time: three panels opening at once must not spawn three CLIs.
|
|
137
|
+
if (inflight) return inflight;
|
|
138
|
+
inflight = measure()
|
|
139
|
+
.then((r) => {
|
|
140
|
+
cached = { ...r, checkedAt: Date.now() };
|
|
141
|
+
return cached;
|
|
142
|
+
})
|
|
143
|
+
.finally(() => {
|
|
144
|
+
inflight = null;
|
|
145
|
+
});
|
|
146
|
+
return inflight;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* ── sign-in ─────────────────────────────────────────────────────────────────
|
|
150
|
+
* We do not build a "connect with Claude" of our own — there is no such thing for a
|
|
151
|
+
* third-party app, and faking one would be exactly the wrong instinct. What we do is
|
|
152
|
+
* run Anthropic's own `claude auth login` here and HAND THE USER ITS LINK.
|
|
153
|
+
*
|
|
154
|
+
* The CLI prints the authorize URL on its first line of output and then waits for the
|
|
155
|
+
* flow to land either way: its loopback callback fires (the browser is on this machine
|
|
156
|
+
* and can reach it), or the person pastes back the code the callback page shows them.
|
|
157
|
+
* Both endings are kept open, which is why stdout is READ instead of thrown away and
|
|
158
|
+
* stdin stays a live pipe — the old version spawned this detached with `stdio:'ignore'`
|
|
159
|
+
* and so could neither show the link nor accept the code, leaving the user with a
|
|
160
|
+
* browser tab and a button that had visibly done nothing.
|
|
161
|
+
*/
|
|
162
|
+
|
|
163
|
+
/** The one login in flight: { child, url, done, ok, startedAt }. */
|
|
164
|
+
let login = null;
|
|
165
|
+
/** An abandoned sign-in must not leave a CLI sitting on the machine forever. */
|
|
166
|
+
const LOGIN_TTL_MS = 10 * 60 * 1000;
|
|
167
|
+
const URL_RE = /(https?:\/\/[^\s"'<>]+)/;
|
|
168
|
+
|
|
169
|
+
function endLogin() {
|
|
170
|
+
if (!login || login.done) return;
|
|
171
|
+
login.done = true;
|
|
172
|
+
try {
|
|
173
|
+
login.child.kill('SIGKILL');
|
|
174
|
+
} catch {
|
|
175
|
+
/* already gone */
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Launch the sign-in and return the URL to put in front of the user.
|
|
181
|
+
*
|
|
182
|
+
* @returns {Promise<{ok:boolean, url?:string, reason?:string}>}
|
|
183
|
+
*/
|
|
184
|
+
export async function startLogin() {
|
|
185
|
+
/* Idempotent while one is running. Every spawn mints a FRESH PKCE challenge and
|
|
186
|
+
invalidates the previous one, so re-opening the dialog (or a second panel asking)
|
|
187
|
+
must hand back the SAME link — otherwise the URL the user already copied dies in
|
|
188
|
+
their clipboard and the sign-in fails with nothing on screen to explain why. */
|
|
189
|
+
if (login && !login.done && login.url && Date.now() - login.startedAt < LOGIN_TTL_MS) {
|
|
190
|
+
return { ok: true, url: login.url };
|
|
191
|
+
}
|
|
192
|
+
endLogin();
|
|
193
|
+
|
|
194
|
+
const probe = await run(['--version'], { timeoutMs: 10_000 });
|
|
195
|
+
if (probe.err === 'not-installed') return { ok: false, reason: 'cli-missing' };
|
|
196
|
+
|
|
197
|
+
let child;
|
|
198
|
+
try {
|
|
199
|
+
child = spawnClaude(['auth', 'login', '--claudeai'], {
|
|
200
|
+
env: cleanEnv(),
|
|
201
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
202
|
+
});
|
|
203
|
+
} catch {
|
|
204
|
+
return { ok: false, reason: 'login-failed' };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const state = { child, url: null, done: false, ok: false, startedAt: Date.now() };
|
|
208
|
+
login = state;
|
|
209
|
+
cached = null; // the answer is about to change
|
|
210
|
+
|
|
211
|
+
const ttl = setTimeout(endLogin, LOGIN_TTL_MS);
|
|
212
|
+
ttl.unref?.();
|
|
213
|
+
|
|
214
|
+
return new Promise((resolve) => {
|
|
215
|
+
let out = '';
|
|
216
|
+
let settled = false;
|
|
217
|
+
const give = (r) => {
|
|
218
|
+
if (settled) return;
|
|
219
|
+
settled = true;
|
|
220
|
+
clearTimeout(wait);
|
|
221
|
+
resolve(r);
|
|
222
|
+
};
|
|
223
|
+
/* The URL is printed within a second; if it somehow is not, say so rather than
|
|
224
|
+
hang the dialog — the CLI may still be opening the browser by itself, which is
|
|
225
|
+
the pre-existing behaviour and a survivable outcome. */
|
|
226
|
+
const wait = setTimeout(() => give({ ok: true, url: null }), 12_000);
|
|
227
|
+
|
|
228
|
+
const read = (chunk) => {
|
|
229
|
+
out += chunk;
|
|
230
|
+
if (!state.url) {
|
|
231
|
+
// "If the browser didn't open, visit: https://claude.com/cai/oauth/authorize?…"
|
|
232
|
+
const line = out.split('\n').find((l) => /visit:/i.test(l) && URL_RE.test(l));
|
|
233
|
+
const m = line?.match(URL_RE);
|
|
234
|
+
if (m) {
|
|
235
|
+
state.url = m[1];
|
|
236
|
+
give({ ok: true, url: m[1] });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/* The CLI says so out loud when the loopback callback lands. Busting the cache
|
|
240
|
+
here means the panel's next poll turns green immediately instead of serving a
|
|
241
|
+
60-second-old "not signed in". */
|
|
242
|
+
if (/login successful/i.test(out)) {
|
|
243
|
+
state.ok = true;
|
|
244
|
+
cached = null;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
child.stdout.on('data', read);
|
|
248
|
+
child.stderr.on('data', read);
|
|
249
|
+
child.on('error', () => {
|
|
250
|
+
state.done = true;
|
|
251
|
+
give({ ok: false, reason: 'login-failed' });
|
|
252
|
+
});
|
|
253
|
+
child.on('close', () => {
|
|
254
|
+
state.done = true;
|
|
255
|
+
clearTimeout(ttl);
|
|
256
|
+
cached = null;
|
|
257
|
+
give({ ok: !!state.url, url: state.url ?? undefined, reason: state.url ? undefined : 'login-failed' });
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Hand the CLI the code from the callback page.
|
|
264
|
+
*
|
|
265
|
+
* Only needed when the browser could not reach the loopback callback — a different
|
|
266
|
+
* machine, or a browser that refuses to open localhost. Then Anthropic's page shows a
|
|
267
|
+
* code instead of redirecting, and this is where it goes.
|
|
268
|
+
*
|
|
269
|
+
* @param {string} code
|
|
270
|
+
* @returns {{ok:boolean, reason?:string}}
|
|
271
|
+
*/
|
|
272
|
+
export function submitLoginCode(code) {
|
|
273
|
+
const value = String(code ?? '').trim();
|
|
274
|
+
if (!value) return { ok: false, reason: 'empty-code' };
|
|
275
|
+
if (!login || login.done || !login.child.stdin.writable) return { ok: false, reason: 'no-login' };
|
|
276
|
+
try {
|
|
277
|
+
login.child.stdin.write(value + '\n');
|
|
278
|
+
} catch {
|
|
279
|
+
return { ok: false, reason: 'no-login' };
|
|
280
|
+
}
|
|
281
|
+
cached = null;
|
|
282
|
+
return { ok: true };
|
|
283
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/** In-memory job registry + tiny concurrency queue (max 2 claude processes). */
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { uid } from './store.mjs';
|
|
4
|
+
|
|
5
|
+
/** Live event bus for the SSE stream (GET /api/stream). Every job log line, every
|
|
6
|
+
* status transition, and (from server.mjs) every vacancy found is emitted here as a
|
|
7
|
+
* single 'event' with a `kind`. The 2.5 s state poll stays the source of truth; this
|
|
8
|
+
* bus only pushes the same facts instantly so the live-agent theatre feels real-time.
|
|
9
|
+
* Many tabs may subscribe, so lift the default 10-listener cap. */
|
|
10
|
+
export const jobEvents = new EventEmitter();
|
|
11
|
+
jobEvents.setMaxListeners(64);
|
|
12
|
+
/** Emit a job status/transition frame. */
|
|
13
|
+
function emitJob(job) {
|
|
14
|
+
jobEvents.emit('event', { kind: 'job', jobId: job.id, jobType: job.type, label: job.label, status: job.status, at: Date.now() });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const jobs = new Map();
|
|
18
|
+
const controllers = new Map(); // job.id → AbortController (только у выполняющихся)
|
|
19
|
+
const MAX_CONCURRENT = 2;
|
|
20
|
+
let running = 0;
|
|
21
|
+
const queue = [];
|
|
22
|
+
|
|
23
|
+
export function createJob(type, label, work) {
|
|
24
|
+
const job = {
|
|
25
|
+
id: uid(),
|
|
26
|
+
type,
|
|
27
|
+
label,
|
|
28
|
+
status: 'queued',
|
|
29
|
+
log: [],
|
|
30
|
+
result: null,
|
|
31
|
+
error: null,
|
|
32
|
+
createdAt: Date.now(),
|
|
33
|
+
finishedAt: null,
|
|
34
|
+
};
|
|
35
|
+
jobs.set(job.id, job);
|
|
36
|
+
queue.push({ job, work });
|
|
37
|
+
pump();
|
|
38
|
+
// Keep the registry bounded.
|
|
39
|
+
if (jobs.size > 100) {
|
|
40
|
+
const finished = [...jobs.values()].filter(j => j.finishedAt).sort((a, b) => a.finishedAt - b.finishedAt);
|
|
41
|
+
for (const old of finished.slice(0, jobs.size - 100)) jobs.delete(old.id);
|
|
42
|
+
}
|
|
43
|
+
return job;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function pump() {
|
|
47
|
+
while (running < MAX_CONCURRENT && queue.length) {
|
|
48
|
+
const { job, work } = queue.shift();
|
|
49
|
+
running++;
|
|
50
|
+
job.status = 'running';
|
|
51
|
+
emitJob(job);
|
|
52
|
+
const ac = new AbortController();
|
|
53
|
+
controllers.set(job.id, ac);
|
|
54
|
+
jobLog(job, `started: ${job.label}`);
|
|
55
|
+
Promise.resolve()
|
|
56
|
+
.then(() => work(job, ac.signal))
|
|
57
|
+
.then(result => {
|
|
58
|
+
job.status = 'done';
|
|
59
|
+
job.result = result ?? null;
|
|
60
|
+
})
|
|
61
|
+
.catch(err => {
|
|
62
|
+
if (ac.signal.aborted) {
|
|
63
|
+
job.status = 'cancelled';
|
|
64
|
+
jobLog(job, '⏹ отменено');
|
|
65
|
+
} else {
|
|
66
|
+
job.status = 'error';
|
|
67
|
+
job.error = String(err?.message || err).slice(0, 2000);
|
|
68
|
+
jobLog(job, `✗ ${job.error}`);
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
.finally(() => {
|
|
72
|
+
emitJob(job);
|
|
73
|
+
controllers.delete(job.id);
|
|
74
|
+
job.finishedAt = Date.now();
|
|
75
|
+
running--;
|
|
76
|
+
pump();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Отмена: из очереди — мгновенно, выполняющуюся — через abort (kill агента). */
|
|
82
|
+
export function cancelJob(id) {
|
|
83
|
+
const job = jobs.get(id);
|
|
84
|
+
if (!job) return false;
|
|
85
|
+
if (job.status === 'queued') {
|
|
86
|
+
const idx = queue.findIndex(q => q.job.id === id);
|
|
87
|
+
if (idx >= 0) queue.splice(idx, 1);
|
|
88
|
+
job.status = 'cancelled';
|
|
89
|
+
job.finishedAt = Date.now();
|
|
90
|
+
jobLog(job, '⏹ отменено (не начиналось)');
|
|
91
|
+
emitJob(job);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
if (job.status === 'running') {
|
|
95
|
+
controllers.get(id)?.abort();
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function cancelActiveByLabel(label) {
|
|
102
|
+
let n = 0;
|
|
103
|
+
for (const j of jobs.values()) {
|
|
104
|
+
if (j.label === label && (j.status === 'running' || j.status === 'queued') && cancelJob(j.id)) n++;
|
|
105
|
+
}
|
|
106
|
+
return n;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function jobLog(job, line) {
|
|
110
|
+
const stored = `${new Date().toLocaleTimeString('uk-UA')} ${line}`;
|
|
111
|
+
job.log.push(stored);
|
|
112
|
+
if (job.log.length > 300) job.log.splice(0, job.log.length - 300);
|
|
113
|
+
// Push the SAME stored line the poll would deliver, so the client's parseLogLine
|
|
114
|
+
// handles a streamed line identically — but instantly and without the last-12 cap.
|
|
115
|
+
jobEvents.emit('event', { kind: 'log', jobId: job.id, jobType: job.type, label: job.label, status: job.status, line: stored, at: Date.now() });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function listJobs() {
|
|
119
|
+
return [...jobs.values()]
|
|
120
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
121
|
+
.slice(0, 25)
|
|
122
|
+
.map(j => ({ ...j, log: j.log.slice(-12) }));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function getJob(id) {
|
|
126
|
+
return jobs.get(id) || null;
|
|
127
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* License verification against the Qiksy licensing worker (products/licenses).
|
|
3
|
+
*
|
|
4
|
+
* Local-first: this backend runs on the user's machine, so a plan sitting in
|
|
5
|
+
* their own db.json can't be trusted — paid tiers are unlocked by a license key
|
|
6
|
+
* validated HERE. Verification is best-effort and cached in db.settings.licenseInfo:
|
|
7
|
+
* • a network failure NEVER downgrades a still-valid cached license (offline grace) —
|
|
8
|
+
* it only refuses to UPGRADE without a fresh check;
|
|
9
|
+
* • an explicit `{ok:false}` (revoked / wrong product) does drop the plan to free.
|
|
10
|
+
*/
|
|
11
|
+
const LICENSE_API = (process.env.QIKSY_LICENSE_API || 'https://qiksy-licenses.qiksy.workers.dev').replace(/\/+$/, '');
|
|
12
|
+
const PRODUCT = 'workfinder';
|
|
13
|
+
export const RECHECK_MS = 6 * 3600 * 1000; // re-verify at most every 6h
|
|
14
|
+
export const OFFLINE_GRACE_MS = 7 * 86400 * 1000; // trust a cached paid plan up to 7 days offline
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Ask the worker about a key. Returns a licenseInfo snapshot, or null when the
|
|
18
|
+
* check couldn't complete (offline / timeout) so the caller keeps the old cache.
|
|
19
|
+
* @param {string} key
|
|
20
|
+
* @returns {Promise<{plan:string, active:boolean, paidUntil:number, trial:boolean, checkedAt:number, invalid?:boolean}|null>}
|
|
21
|
+
*/
|
|
22
|
+
export async function verifyLicense(key) {
|
|
23
|
+
if (!key) return { plan: 'free', active: false, paidUntil: 0, trial: false, checkedAt: Date.now() };
|
|
24
|
+
try {
|
|
25
|
+
const signal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(4000) : undefined;
|
|
26
|
+
const r = await fetch(`${LICENSE_API}/v?key=${encodeURIComponent(key)}&product=${PRODUCT}`, { signal });
|
|
27
|
+
if (!r.ok) return null;
|
|
28
|
+
const j = await r.json();
|
|
29
|
+
if (!j || j.ok === false) return { plan: 'free', active: false, paidUntil: 0, trial: false, checkedAt: Date.now(), invalid: true };
|
|
30
|
+
return {
|
|
31
|
+
plan: j.plan || 'free',
|
|
32
|
+
active: !!j.active,
|
|
33
|
+
paidUntil: j.paidUntil || 0,
|
|
34
|
+
trial: !!j.trial,
|
|
35
|
+
// Team = a Pro license bought on N seats; the worker carries the count. Drives
|
|
36
|
+
// the candidate limit (server maxProfilesEff). Absent/1 ⇒ individual Pro.
|
|
37
|
+
seats: Number.isFinite(j.seats) && j.seats > 0 ? j.seats : null,
|
|
38
|
+
checkedAt: Date.now(),
|
|
39
|
+
};
|
|
40
|
+
} catch {
|
|
41
|
+
return null; // offline: caller keeps the cached licenseInfo
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Effective plan id from a cached licenseInfo. `dev` (self-host) is decided by
|
|
47
|
+
* the caller before this. Returns 'free' unless the license is a live paid plan
|
|
48
|
+
* (or within offline grace of one).
|
|
49
|
+
*/
|
|
50
|
+
export function licensePlanId(licenseInfo) {
|
|
51
|
+
const li = licenseInfo;
|
|
52
|
+
if (!li || !li.plan || li.plan === 'free') return 'free';
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
const notExpired = !li.paidUntil || li.paidUntil > now;
|
|
55
|
+
if (li.active && notExpired) return li.plan;
|
|
56
|
+
// offline grace: last check was recent-ish AND the paid term hasn't lapsed
|
|
57
|
+
const offlineOk = li.checkedAt && now - li.checkedAt < OFFLINE_GRACE_MS;
|
|
58
|
+
if (offlineOk && li.paidUntil > now) return li.plan;
|
|
59
|
+
return 'free';
|
|
60
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Проверка «живости» ссылки на вакансию обычным HTTP-запросом — без токенов.
|
|
3
|
+
* Мёртвой считаем: HTTP >= 400, страницу-надгробие («job not found», «no longer
|
|
4
|
+
* accepting»…) или редирект, потерявший хвост пути (Lever так уводит закрытые
|
|
5
|
+
* вакансии на общий список компании).
|
|
6
|
+
*/
|
|
7
|
+
const TOMBSTONE = new RegExp(
|
|
8
|
+
[
|
|
9
|
+
'job (you( are|\\u2019re|\'re)? looking for )?(is |was )?not found',
|
|
10
|
+
'no longer (accepting|available|active|open|exists)',
|
|
11
|
+
'position (has been |was )?(filled|closed|removed)',
|
|
12
|
+
'posting (not found|has closed|is no longer)',
|
|
13
|
+
'this (job|position|listing|opening) (has |is |was )?(expired|closed|been removed|no longer)',
|
|
14
|
+
'vacancy (is )?(closed|not found)',
|
|
15
|
+
'job not found',
|
|
16
|
+
'page not found',
|
|
17
|
+
'doesn\\u2019t exist',
|
|
18
|
+
].join('|'),
|
|
19
|
+
'i',
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36';
|
|
23
|
+
|
|
24
|
+
export async function checkVacancyUrl(url) {
|
|
25
|
+
if (!url) return { ok: false, reason: 'нет ссылки' };
|
|
26
|
+
let res;
|
|
27
|
+
try {
|
|
28
|
+
res = await fetch(url, {
|
|
29
|
+
redirect: 'follow',
|
|
30
|
+
signal: AbortSignal.timeout(9000),
|
|
31
|
+
headers: { 'user-agent': UA, accept: 'text/html,*/*' },
|
|
32
|
+
});
|
|
33
|
+
} catch {
|
|
34
|
+
return { ok: false, reason: 'сайт не ответил' };
|
|
35
|
+
}
|
|
36
|
+
// 403/429 — почти всегда анти-бот (Cloudflare и т.п.), в браузере страница жива
|
|
37
|
+
if (res.status === 403 || res.status === 429) return { ok: true, unverified: true };
|
|
38
|
+
if (res.status >= 400) return { ok: false, reason: `HTTP ${res.status}` };
|
|
39
|
+
|
|
40
|
+
// редирект, срезавший идентификатор вакансии из пути → общий список
|
|
41
|
+
try {
|
|
42
|
+
const orig = new URL(url);
|
|
43
|
+
const fin = new URL(res.url || url);
|
|
44
|
+
const trim = p => p.replace(/\/+$/, '');
|
|
45
|
+
if (
|
|
46
|
+
fin.hostname === orig.hostname
|
|
47
|
+
&& trim(fin.pathname) !== trim(orig.pathname)
|
|
48
|
+
&& trim(orig.pathname).startsWith(trim(fin.pathname))
|
|
49
|
+
&& trim(orig.pathname).slice(trim(fin.pathname).length).length > 6
|
|
50
|
+
) {
|
|
51
|
+
return { ok: false, reason: 'редирект на общий список вакансий' };
|
|
52
|
+
}
|
|
53
|
+
} catch { /* не критично */ }
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const text = (await res.text()).slice(0, 300_000);
|
|
57
|
+
if (TOMBSTONE.test(text)) return { ok: false, reason: 'страница сообщает, что вакансия закрыта' };
|
|
58
|
+
} catch { /* тело не дочиталось — не считаем мёртвой */ }
|
|
59
|
+
return { ok: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Проверить набор объектов с url параллельно (пачками), вернуть Map url→result. */
|
|
63
|
+
export async function checkMany(urls, concurrency = 8) {
|
|
64
|
+
const unique = [...new Set(urls.filter(Boolean))];
|
|
65
|
+
const results = new Map();
|
|
66
|
+
for (let i = 0; i < unique.length; i += concurrency) {
|
|
67
|
+
const chunk = unique.slice(i, i + concurrency);
|
|
68
|
+
const checked = await Promise.all(chunk.map(u => checkVacancyUrl(u)));
|
|
69
|
+
chunk.forEach((u, idx) => results.set(u, checked[idx]));
|
|
70
|
+
}
|
|
71
|
+
return results;
|
|
72
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkedIn profile URLs, as people actually have them.
|
|
3
|
+
*
|
|
4
|
+
* LinkedIn's own "Copy link to profile" and the browser address bar hand out
|
|
5
|
+
* `www.linkedin.com/in/name` — no scheme — and the site serves country subdomains
|
|
6
|
+
* (`ua.linkedin.com`, `de.linkedin.com`). The original check demanded
|
|
7
|
+
* `https://[www.]linkedin.com/in/` and rejected every one of those as malformed, so a
|
|
8
|
+
* real profile URL pasted straight from LinkedIn came back "invalid".
|
|
9
|
+
*
|
|
10
|
+
* Accept what LinkedIn gives out; store one canonical form.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} raw
|
|
13
|
+
* @returns {string|null} canonical URL, or null if it is not a profile link at all
|
|
14
|
+
*/
|
|
15
|
+
export function normalizeLinkedin(raw) {
|
|
16
|
+
const bare = String(raw || '')
|
|
17
|
+
.trim()
|
|
18
|
+
.replace(/^https?:\/\//i, '')
|
|
19
|
+
.replace(/^www\./i, '');
|
|
20
|
+
const m = /^(?:[a-z]{2,3}\.)?linkedin\.com\/in\/([^/?#\s]+)/i.exec(bare);
|
|
21
|
+
return m ? `https://www.linkedin.com/in/${m[1]}` : null;
|
|
22
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Тарифы. ЕДИНСТВЕННАЯ ось — количество кандидатов (maxProfiles). Никаких
|
|
3
|
+
* кредитов и «кошелька»: юзер работает на СВОЁМ ключе Claude, значит частота
|
|
4
|
+
* запросов упирается в его тариф Claude, а не в нашу валюту. Мы монетизируем
|
|
5
|
+
* только число юзеров/профилей — обычный разработчик ищет работу себе одному
|
|
6
|
+
* бесплатно и без ограничений; больше профилей (рекрутеры, агентства) — платно.
|
|
7
|
+
*
|
|
8
|
+
* Платные start/pro включаются ТОЛЬКО валидной лицензией (lib/license.mjs) —
|
|
9
|
+
* local-first, план в db.json доверять нельзя. Руками — только free/dev.
|
|
10
|
+
* priceUsd/period — для витрины (реальная цена живёт в нашей кассе, MONO_PRICE_*).
|
|
11
|
+
*/
|
|
12
|
+
// ДВЕ оси метрики (owner 2026-07-25): число кандидатов (maxProfiles) И число ролей
|
|
13
|
+
// НА кандидата (maxRoles). Без второй оси один кандидат с 30 ролями = работа десяти
|
|
14
|
+
// кандидатов забесплатно — роли обходили кандидат-лимит. Реальный человек фитит 2–5
|
|
15
|
+
// близких тайтла; РАЗНЫЕ профессии = разные люди = разные кандидаты (Team).
|
|
16
|
+
// Модель цены зафиксирована 2026-07-25: ОДИН глобальный прайс, без регионалки/гейта.
|
|
17
|
+
// Free 1 кандидат · Pro $129/год 1 кандидат · Team = тот же Pro, купленный на N МЕСТ
|
|
18
|
+
// (лицензия несёт `seats`, которое поднимает maxProfiles) — отдельного плана нет.
|
|
19
|
+
// maxRoles — роли НА кандидата (вторая ось метрики, закрывает «30 ролей в одном»).
|
|
20
|
+
export const PLANS = {
|
|
21
|
+
free: { name: 'Free', maxProfiles: 1, maxRoles: 1, autoApply: false, leads: false, watch: false, priceUsd: 0 },
|
|
22
|
+
// Pro по умолчанию 1 место; лицензия с seats>1 (Team) поднимает лимит кандидатов
|
|
23
|
+
// (см. server.mjs maxProfilesEff). Годовой, без помесячной оплаты для индивидуала.
|
|
24
|
+
// `watch` — мониторинг по расписанию (агент сам перезапускает поиск и ловит новое
|
|
25
|
+
// раньше рынка); это ГЛАВНЫЙ премиум-аргумент, поэтому только на платном.
|
|
26
|
+
pro: { name: 'Pro', maxProfiles: 1, maxRoles: 5, autoApply: true, leads: true, watch: true, priceUsd: 129, period: 'yr' },
|
|
27
|
+
dev: { name: 'Dev — без лимитов', maxProfiles: Infinity, maxRoles: Infinity, autoApply: true, leads: true, watch: true },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Планы, которые можно выставить руками в /api/settings (платные — только по лицензии). */
|
|
31
|
+
export const SELF_SETTABLE_PLANS = ['free', 'dev'];
|
|
32
|
+
|
|
33
|
+
export const monthKey = () => new Date().toISOString().slice(0, 7);
|