amaprice 1.0.12 → 1.0.14
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/README.md +28 -0
- package/bin/cli.js +2 -0
- package/package.json +1 -1
- package/src/background/service.js +515 -0
- package/src/collector/client.js +3 -3
- package/src/collector/daemon-entry.js +22 -0
- package/src/collector/state.js +1 -0
- package/src/commands/background.js +97 -0
- package/src/commands/collector.js +5 -5
- package/src/commands/subscribe.js +8 -0
- package/src/commands/track.js +8 -0
package/README.md
CHANGED
|
@@ -35,6 +35,9 @@ amaprice price "https://www.amazon.de/dp/B0DZ5P7JD6"
|
|
|
35
35
|
# start tracking with a tier
|
|
36
36
|
amaprice track B0DZ5P7JD6 --tier daily
|
|
37
37
|
|
|
38
|
+
# subscribe current user to shared catalog product
|
|
39
|
+
amaprice subscribe B0DZ5P7JD6
|
|
40
|
+
|
|
38
41
|
# show history
|
|
39
42
|
amaprice history B0DZ5P7JD6 --limit 30
|
|
40
43
|
|
|
@@ -58,13 +61,35 @@ Short links from Amazon apps (for example `amzn.eu`, `amzn.to`, `a.co`) are acce
|
|
|
58
61
|
| `amaprice [url\|asin]` | Shortcut for `amaprice price [url\|asin]` |
|
|
59
62
|
| `amaprice price [url\|asin]` | One-shot lookup and silent history insert |
|
|
60
63
|
| `amaprice track [url\|asin]` | Track product + current price (`--tier`, `--manual-tier`, `--auto-tier`, `--inactive`) |
|
|
64
|
+
| `amaprice subscribe [url\|asin]` | Subscribe current user to shared product catalog entry |
|
|
65
|
+
| `amaprice unsubscribe <url\|asin>` | Disable current user subscription |
|
|
66
|
+
| `amaprice subscriptions` | List user subscriptions with latest known prices |
|
|
61
67
|
| `amaprice history <url\|asin>` | Show history (`--limit N`) |
|
|
62
68
|
| `amaprice list` | List tracked products + latest price |
|
|
63
69
|
| `amaprice sync --limit <n>` | Run background sync for due products |
|
|
70
|
+
| `amaprice background <on\|off\|status>` | Manage true background collector service |
|
|
64
71
|
| `amaprice tier <url\|asin> <hourly\|daily\|weekly>` | Set tier/status (`--auto`, `--manual`, `--activate`, `--deactivate`) |
|
|
65
72
|
|
|
66
73
|
All commands support `--json`.
|
|
67
74
|
|
|
75
|
+
## Background Service (Auto)
|
|
76
|
+
|
|
77
|
+
`track` and `subscribe` automatically ensure a true background collector service is running.
|
|
78
|
+
|
|
79
|
+
This service:
|
|
80
|
+
- keeps running after terminal close
|
|
81
|
+
- survives shell sessions
|
|
82
|
+
- polls queue jobs every `180s` by default
|
|
83
|
+
- currently uses `launchd` on macOS
|
|
84
|
+
|
|
85
|
+
Simple control commands:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
amaprice background status
|
|
89
|
+
amaprice background on
|
|
90
|
+
amaprice background off
|
|
91
|
+
```
|
|
92
|
+
|
|
68
93
|
## Currently Supported Store
|
|
69
94
|
|
|
70
95
|
Amazon domains:
|
|
@@ -177,6 +202,9 @@ Environment variables used by the npm package:
|
|
|
177
202
|
| `SYNC_INTERVAL_MINUTES` | `5` | `src/worker.js` | Worker loop interval |
|
|
178
203
|
| `SYNC_LIMIT` | `20` | `src/worker.js`, `amaprice sync --limit` | Max due products per run |
|
|
179
204
|
| `SYNC_RUN_ONCE` | `0` | `src/worker.js` | Set `1` for single run and exit |
|
|
205
|
+
| `AMAPRICE_AUTO_BACKGROUND` | `1` | `track`, `subscribe` | Set `0` to disable auto background startup |
|
|
206
|
+
| `COLLECTOR_POLL_SECONDS` | `180` | background collector service | Queue poll interval |
|
|
207
|
+
| `COLLECTOR_LIMIT` | `10` | background collector service | Max claimed jobs per poll |
|
|
180
208
|
| `VISION_FALLBACK_ENABLED` | `0` | `src/extractors/pipeline.js` | Enable screenshot + vision fallback when HTML/JSON extraction fails |
|
|
181
209
|
| `OPENROUTER_API_KEY` | none | `src/extractors/vision.js` | Preferred vision provider key |
|
|
182
210
|
| `VISION_MODEL` | `google/gemini-3-flash-preview` | `src/extractors/vision.js` | OpenRouter model ID for vision extraction |
|
package/bin/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ const KNOWN_COMMANDS = new Set([
|
|
|
12
12
|
'subscribe',
|
|
13
13
|
'unsubscribe',
|
|
14
14
|
'subscriptions',
|
|
15
|
+
'background',
|
|
15
16
|
'collector',
|
|
16
17
|
'help',
|
|
17
18
|
]);
|
|
@@ -40,6 +41,7 @@ require('../src/commands/tier')(program);
|
|
|
40
41
|
require('../src/commands/subscribe')(program);
|
|
41
42
|
require('../src/commands/unsubscribe')(program);
|
|
42
43
|
require('../src/commands/subscriptions')(program);
|
|
44
|
+
require('../src/commands/background')(program);
|
|
43
45
|
require('../src/commands/collector')(program);
|
|
44
46
|
|
|
45
47
|
program.parse();
|
package/package.json
CHANGED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
const fs = require('fs/promises');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const { execFile } = require('child_process');
|
|
5
|
+
const { promisify } = require('util');
|
|
6
|
+
|
|
7
|
+
const { getUserId } = require('../user-context');
|
|
8
|
+
const { readCollectorState, writeCollectorState, getStateDir } = require('../collector/state');
|
|
9
|
+
const { upsertCollector, heartbeatCollector, getCollectorById } = require('../db');
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
const DEFAULT_COLLECTOR_LIMIT = 10;
|
|
14
|
+
const DEFAULT_POLL_SECONDS = 180;
|
|
15
|
+
const MIN_POLL_SECONDS = 30;
|
|
16
|
+
const MAX_POLL_SECONDS = 3600;
|
|
17
|
+
|
|
18
|
+
function sanitizeLabelPart(value) {
|
|
19
|
+
return String(value || '')
|
|
20
|
+
.trim()
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
23
|
+
.replace(/-+/g, '-')
|
|
24
|
+
.replace(/^-+|-+$/g, '') || 'user';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveCollectorLimit(value = null) {
|
|
28
|
+
const parsed = Number(value ?? process.env.COLLECTOR_LIMIT);
|
|
29
|
+
if (!Number.isFinite(parsed)) return DEFAULT_COLLECTOR_LIMIT;
|
|
30
|
+
return Math.min(100, Math.max(1, Math.round(parsed)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolvePollSeconds(value = null) {
|
|
34
|
+
const parsed = Number(value ?? process.env.COLLECTOR_POLL_SECONDS);
|
|
35
|
+
if (!Number.isFinite(parsed)) return DEFAULT_POLL_SECONDS;
|
|
36
|
+
return Math.min(MAX_POLL_SECONDS, Math.max(MIN_POLL_SECONDS, Math.round(parsed)));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getDefaultCollectorName() {
|
|
40
|
+
return `${os.hostname()}-collector`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getDaemonEntryPath() {
|
|
44
|
+
return path.join(__dirname, '../collector/daemon-entry.js');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getLaunchdLabel(userId) {
|
|
48
|
+
return `sh.amaprice.collector.${sanitizeLabelPart(userId)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getLaunchdPlistPath(label) {
|
|
52
|
+
return path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getDaemonLogPath() {
|
|
56
|
+
return path.join(getStateDir(), 'collector-daemon.log');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function xmlEscape(value) {
|
|
60
|
+
return String(value)
|
|
61
|
+
.replace(/&/g, '&')
|
|
62
|
+
.replace(/</g, '<')
|
|
63
|
+
.replace(/>/g, '>')
|
|
64
|
+
.replace(/"/g, '"')
|
|
65
|
+
.replace(/'/g, ''');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function renderLaunchdPlist({
|
|
69
|
+
label,
|
|
70
|
+
programArguments,
|
|
71
|
+
stdoutPath,
|
|
72
|
+
stderrPath,
|
|
73
|
+
environment = {},
|
|
74
|
+
}) {
|
|
75
|
+
const argsXml = (programArguments || [])
|
|
76
|
+
.map((arg) => ` <string>${xmlEscape(arg)}</string>`)
|
|
77
|
+
.join('\n');
|
|
78
|
+
|
|
79
|
+
const envRows = Object.entries(environment || {})
|
|
80
|
+
.filter(([, value]) => value != null && String(value).trim() !== '')
|
|
81
|
+
.map(([key, value]) => (
|
|
82
|
+
` <key>${xmlEscape(key)}</key>\n <string>${xmlEscape(value)}</string>`
|
|
83
|
+
))
|
|
84
|
+
.join('\n');
|
|
85
|
+
|
|
86
|
+
const envXml = envRows
|
|
87
|
+
? `\n <key>EnvironmentVariables</key>\n <dict>\n${envRows}\n </dict>`
|
|
88
|
+
: '';
|
|
89
|
+
|
|
90
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
91
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
92
|
+
<plist version="1.0">
|
|
93
|
+
<dict>
|
|
94
|
+
<key>Label</key>
|
|
95
|
+
<string>${xmlEscape(label)}</string>
|
|
96
|
+
<key>ProgramArguments</key>
|
|
97
|
+
<array>
|
|
98
|
+
${argsXml}
|
|
99
|
+
</array>
|
|
100
|
+
<key>RunAtLoad</key>
|
|
101
|
+
<true/>
|
|
102
|
+
<key>KeepAlive</key>
|
|
103
|
+
<true/>
|
|
104
|
+
<key>StandardOutPath</key>
|
|
105
|
+
<string>${xmlEscape(stdoutPath)}</string>
|
|
106
|
+
<key>StandardErrorPath</key>
|
|
107
|
+
<string>${xmlEscape(stderrPath)}</string>${envXml}
|
|
108
|
+
</dict>
|
|
109
|
+
</plist>
|
|
110
|
+
`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isLaunchdSupported(platform = process.platform) {
|
|
114
|
+
return platform === 'darwin';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getLaunchdDomain() {
|
|
118
|
+
if (typeof process.getuid !== 'function') {
|
|
119
|
+
throw new Error('launchd requires a POSIX uid');
|
|
120
|
+
}
|
|
121
|
+
return `gui/${process.getuid()}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildServiceTarget(label) {
|
|
125
|
+
return `${getLaunchdDomain()}/${label}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function runLaunchctl(args, { allowFailure = false } = {}) {
|
|
129
|
+
try {
|
|
130
|
+
const out = await execFileAsync('launchctl', args);
|
|
131
|
+
return {
|
|
132
|
+
ok: true,
|
|
133
|
+
stdout: String(out.stdout || ''),
|
|
134
|
+
stderr: String(out.stderr || ''),
|
|
135
|
+
};
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if (allowFailure) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
stdout: String(err.stdout || ''),
|
|
141
|
+
stderr: String(err.stderr || ''),
|
|
142
|
+
error: err,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const stderr = String(err.stderr || err.message || '').trim();
|
|
146
|
+
throw new Error(`launchctl ${args.join(' ')} failed: ${stderr || 'unknown error'}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isAlreadyLoadedError(result) {
|
|
151
|
+
const text = `${result?.stderr || ''}\n${result?.stdout || ''}`.toLowerCase();
|
|
152
|
+
return text.includes('already loaded') || text.includes('service already loaded');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function pickDaemonEnvironment(userId) {
|
|
156
|
+
const passthrough = [
|
|
157
|
+
'SUPABASE_URL',
|
|
158
|
+
'SUPABASE_KEY',
|
|
159
|
+
'SUPABASE_ANON_KEY',
|
|
160
|
+
'ORCHESTRATOR_ENABLED',
|
|
161
|
+
'VISION_FALLBACK_ENABLED',
|
|
162
|
+
'OPENROUTER_API_KEY',
|
|
163
|
+
'VISION_MODEL',
|
|
164
|
+
'VISION_PROVIDER',
|
|
165
|
+
'OPENROUTER_HTTP_REFERER',
|
|
166
|
+
'OPENROUTER_TITLE',
|
|
167
|
+
'VISION_GUARDRAIL_ENABLED',
|
|
168
|
+
'VISION_GUARDRAIL_MIN_CONFIDENCE',
|
|
169
|
+
'VISION_GUARDRAIL_MAX_REL_DELTA',
|
|
170
|
+
'PATH',
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
const env = {
|
|
174
|
+
AMAPRICE_USER_ID: userId,
|
|
175
|
+
};
|
|
176
|
+
for (const key of passthrough) {
|
|
177
|
+
if (process.env[key]) env[key] = process.env[key];
|
|
178
|
+
}
|
|
179
|
+
return env;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function ensureCollectorEnabled({
|
|
183
|
+
userId = getUserId(),
|
|
184
|
+
collectorName = null,
|
|
185
|
+
status = 'active',
|
|
186
|
+
capabilities = null,
|
|
187
|
+
} = {}) {
|
|
188
|
+
const existing = await readCollectorState();
|
|
189
|
+
const collector = await upsertCollector({
|
|
190
|
+
collectorId: existing?.collectorId || null,
|
|
191
|
+
userId,
|
|
192
|
+
name: collectorName || existing?.name || getDefaultCollectorName(),
|
|
193
|
+
kind: 'cli',
|
|
194
|
+
status,
|
|
195
|
+
capabilities: capabilities || existing?.capabilities || {
|
|
196
|
+
html_json: true,
|
|
197
|
+
vision: true,
|
|
198
|
+
railway_dom: true,
|
|
199
|
+
},
|
|
200
|
+
metadata: {
|
|
201
|
+
platform: process.platform,
|
|
202
|
+
node: process.version,
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const state = {
|
|
207
|
+
collectorId: collector.id,
|
|
208
|
+
userId,
|
|
209
|
+
name: collector.name,
|
|
210
|
+
status,
|
|
211
|
+
capabilities: collector.capabilities,
|
|
212
|
+
enabledAt: existing?.enabledAt || new Date().toISOString(),
|
|
213
|
+
updatedAt: new Date().toISOString(),
|
|
214
|
+
background: {
|
|
215
|
+
...(existing?.background || {}),
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
const statePath = await writeCollectorState(state);
|
|
219
|
+
return { collector, state, statePath };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function getLaunchdServiceStatus({ label }) {
|
|
223
|
+
const plistPath = getLaunchdPlistPath(label);
|
|
224
|
+
let installed = true;
|
|
225
|
+
try {
|
|
226
|
+
await fs.access(plistPath);
|
|
227
|
+
} catch {
|
|
228
|
+
installed = false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!installed) {
|
|
232
|
+
return {
|
|
233
|
+
backend: 'launchd',
|
|
234
|
+
label,
|
|
235
|
+
plistPath,
|
|
236
|
+
installed: false,
|
|
237
|
+
loaded: false,
|
|
238
|
+
running: false,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const print = await runLaunchctl(['print', buildServiceTarget(label)], { allowFailure: true });
|
|
243
|
+
const output = `${print.stdout}\n${print.stderr}`;
|
|
244
|
+
const loaded = print.ok;
|
|
245
|
+
const running = loaded && (/state = running/i.test(output) || /pid = \d+/i.test(output));
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
backend: 'launchd',
|
|
249
|
+
label,
|
|
250
|
+
plistPath,
|
|
251
|
+
installed: true,
|
|
252
|
+
loaded,
|
|
253
|
+
running,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function enableLaunchdService({
|
|
258
|
+
label,
|
|
259
|
+
pollSeconds,
|
|
260
|
+
limit,
|
|
261
|
+
userId,
|
|
262
|
+
}) {
|
|
263
|
+
const plistPath = getLaunchdPlistPath(label);
|
|
264
|
+
const logPath = getDaemonLogPath();
|
|
265
|
+
const daemonEntry = getDaemonEntryPath();
|
|
266
|
+
const target = buildServiceTarget(label);
|
|
267
|
+
|
|
268
|
+
await fs.mkdir(path.dirname(plistPath), { recursive: true });
|
|
269
|
+
await fs.mkdir(path.dirname(logPath), { recursive: true });
|
|
270
|
+
|
|
271
|
+
const plist = renderLaunchdPlist({
|
|
272
|
+
label,
|
|
273
|
+
programArguments: [
|
|
274
|
+
process.execPath,
|
|
275
|
+
daemonEntry,
|
|
276
|
+
'--limit',
|
|
277
|
+
String(limit),
|
|
278
|
+
'--poll-seconds',
|
|
279
|
+
String(pollSeconds),
|
|
280
|
+
],
|
|
281
|
+
stdoutPath: logPath,
|
|
282
|
+
stderrPath: logPath,
|
|
283
|
+
environment: pickDaemonEnvironment(userId),
|
|
284
|
+
});
|
|
285
|
+
await fs.writeFile(plistPath, plist, 'utf8');
|
|
286
|
+
|
|
287
|
+
// If service was previously disabled via `background off`, re-enable first.
|
|
288
|
+
await runLaunchctl(['enable', target], { allowFailure: true });
|
|
289
|
+
|
|
290
|
+
let bootstrap = await runLaunchctl(['bootstrap', getLaunchdDomain(), plistPath], { allowFailure: true });
|
|
291
|
+
if (!bootstrap.ok && !isAlreadyLoadedError(bootstrap)) {
|
|
292
|
+
// Recover from stale loaded/disabled state by clearing then bootstrapping once more.
|
|
293
|
+
await runLaunchctl(['bootout', target], { allowFailure: true });
|
|
294
|
+
await runLaunchctl(['enable', target], { allowFailure: true });
|
|
295
|
+
bootstrap = await runLaunchctl(['bootstrap', getLaunchdDomain(), plistPath], { allowFailure: true });
|
|
296
|
+
}
|
|
297
|
+
if (!bootstrap.ok && !isAlreadyLoadedError(bootstrap)) {
|
|
298
|
+
throw new Error(`Could not bootstrap launchd service: ${bootstrap.stderr || bootstrap.stdout || 'unknown error'}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
await runLaunchctl(['enable', target], { allowFailure: true });
|
|
302
|
+
const kick = await runLaunchctl(['kickstart', '-k', target], { allowFailure: true });
|
|
303
|
+
if (!kick.ok) {
|
|
304
|
+
await runLaunchctl(['start', label], { allowFailure: true });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return getLaunchdServiceStatus({ label });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function disableLaunchdService({ label }) {
|
|
311
|
+
const plistPath = getLaunchdPlistPath(label);
|
|
312
|
+
await runLaunchctl(['bootout', buildServiceTarget(label)], { allowFailure: true });
|
|
313
|
+
await runLaunchctl(['disable', buildServiceTarget(label)], { allowFailure: true });
|
|
314
|
+
try {
|
|
315
|
+
await fs.unlink(plistPath);
|
|
316
|
+
} catch (err) {
|
|
317
|
+
if (!err || err.code !== 'ENOENT') {
|
|
318
|
+
throw err;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return getLaunchdServiceStatus({ label });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function ensureBackgroundOn({
|
|
326
|
+
userId = getUserId(),
|
|
327
|
+
collectorName = null,
|
|
328
|
+
pollSeconds = null,
|
|
329
|
+
limit = null,
|
|
330
|
+
} = {}) {
|
|
331
|
+
if (!isLaunchdSupported()) {
|
|
332
|
+
return {
|
|
333
|
+
supported: false,
|
|
334
|
+
running: false,
|
|
335
|
+
reason: `unsupported_platform:${process.platform}`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const safePollSeconds = resolvePollSeconds(pollSeconds);
|
|
340
|
+
const safeLimit = resolveCollectorLimit(limit);
|
|
341
|
+
const label = getLaunchdLabel(userId);
|
|
342
|
+
const { collector, statePath } = await ensureCollectorEnabled({
|
|
343
|
+
userId,
|
|
344
|
+
collectorName,
|
|
345
|
+
status: 'active',
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const service = await enableLaunchdService({
|
|
349
|
+
label,
|
|
350
|
+
pollSeconds: safePollSeconds,
|
|
351
|
+
limit: safeLimit,
|
|
352
|
+
userId,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
await heartbeatCollector({
|
|
356
|
+
collectorId: collector.id,
|
|
357
|
+
status: 'active',
|
|
358
|
+
}).catch(() => {});
|
|
359
|
+
|
|
360
|
+
const local = await readCollectorState();
|
|
361
|
+
await writeCollectorState({
|
|
362
|
+
...(local || {}),
|
|
363
|
+
collectorId: collector.id,
|
|
364
|
+
userId,
|
|
365
|
+
name: collector.name,
|
|
366
|
+
status: 'active',
|
|
367
|
+
background: {
|
|
368
|
+
enabled: true,
|
|
369
|
+
backend: 'launchd',
|
|
370
|
+
label,
|
|
371
|
+
pollSeconds: safePollSeconds,
|
|
372
|
+
limit: safeLimit,
|
|
373
|
+
updatedAt: new Date().toISOString(),
|
|
374
|
+
},
|
|
375
|
+
updatedAt: new Date().toISOString(),
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
supported: true,
|
|
380
|
+
running: service.running,
|
|
381
|
+
service,
|
|
382
|
+
statePath,
|
|
383
|
+
collectorId: collector.id,
|
|
384
|
+
pollSeconds: safePollSeconds,
|
|
385
|
+
limit: safeLimit,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function ensureBackgroundOff({
|
|
390
|
+
userId = getUserId(),
|
|
391
|
+
} = {}) {
|
|
392
|
+
const state = await readCollectorState();
|
|
393
|
+
const label = state?.background?.label || getLaunchdLabel(userId);
|
|
394
|
+
|
|
395
|
+
let service = {
|
|
396
|
+
backend: 'launchd',
|
|
397
|
+
label,
|
|
398
|
+
installed: false,
|
|
399
|
+
loaded: false,
|
|
400
|
+
running: false,
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
if (isLaunchdSupported()) {
|
|
404
|
+
service = await disableLaunchdService({ label });
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (state?.collectorId) {
|
|
408
|
+
await heartbeatCollector({
|
|
409
|
+
collectorId: state.collectorId,
|
|
410
|
+
status: 'paused',
|
|
411
|
+
}).catch(() => {});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (state) {
|
|
415
|
+
await writeCollectorState({
|
|
416
|
+
...state,
|
|
417
|
+
status: 'paused',
|
|
418
|
+
background: {
|
|
419
|
+
...(state.background || {}),
|
|
420
|
+
enabled: false,
|
|
421
|
+
backend: 'launchd',
|
|
422
|
+
label,
|
|
423
|
+
updatedAt: new Date().toISOString(),
|
|
424
|
+
},
|
|
425
|
+
updatedAt: new Date().toISOString(),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
supported: isLaunchdSupported(),
|
|
431
|
+
running: false,
|
|
432
|
+
service,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function getBackgroundStatus({
|
|
437
|
+
userId = getUserId(),
|
|
438
|
+
} = {}) {
|
|
439
|
+
const state = await readCollectorState();
|
|
440
|
+
const remote = state?.collectorId
|
|
441
|
+
? await getCollectorById(state.collectorId).catch(() => null)
|
|
442
|
+
: null;
|
|
443
|
+
const label = state?.background?.label || getLaunchdLabel(userId);
|
|
444
|
+
|
|
445
|
+
const service = isLaunchdSupported()
|
|
446
|
+
? await getLaunchdServiceStatus({ label })
|
|
447
|
+
: {
|
|
448
|
+
backend: null,
|
|
449
|
+
label,
|
|
450
|
+
installed: false,
|
|
451
|
+
loaded: false,
|
|
452
|
+
running: false,
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
supported: isLaunchdSupported(),
|
|
457
|
+
userId,
|
|
458
|
+
local: state,
|
|
459
|
+
remote,
|
|
460
|
+
service,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function isAutoBackgroundEnabled() {
|
|
465
|
+
return process.env.AMAPRICE_AUTO_BACKGROUND !== '0';
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function maybeEnsureBackgroundOn({
|
|
469
|
+
userId = getUserId(),
|
|
470
|
+
} = {}) {
|
|
471
|
+
if (!isAutoBackgroundEnabled()) {
|
|
472
|
+
return {
|
|
473
|
+
attempted: false,
|
|
474
|
+
running: false,
|
|
475
|
+
reason: 'disabled_by_env',
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
try {
|
|
480
|
+
const report = await ensureBackgroundOn({ userId });
|
|
481
|
+
return {
|
|
482
|
+
attempted: true,
|
|
483
|
+
...report,
|
|
484
|
+
};
|
|
485
|
+
} catch (err) {
|
|
486
|
+
return {
|
|
487
|
+
attempted: true,
|
|
488
|
+
running: false,
|
|
489
|
+
error: err.message,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
module.exports = {
|
|
495
|
+
DEFAULT_COLLECTOR_LIMIT,
|
|
496
|
+
DEFAULT_POLL_SECONDS,
|
|
497
|
+
MIN_POLL_SECONDS,
|
|
498
|
+
MAX_POLL_SECONDS,
|
|
499
|
+
ensureBackgroundOn,
|
|
500
|
+
ensureBackgroundOff,
|
|
501
|
+
getBackgroundStatus,
|
|
502
|
+
maybeEnsureBackgroundOn,
|
|
503
|
+
resolveCollectorLimit,
|
|
504
|
+
resolvePollSeconds,
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
module.exports.__test = {
|
|
508
|
+
sanitizeLabelPart,
|
|
509
|
+
resolveCollectorLimit,
|
|
510
|
+
resolvePollSeconds,
|
|
511
|
+
getLaunchdLabel,
|
|
512
|
+
getLaunchdPlistPath,
|
|
513
|
+
renderLaunchdPlist,
|
|
514
|
+
isLaunchdSupported,
|
|
515
|
+
};
|
package/src/collector/client.js
CHANGED
|
@@ -14,7 +14,7 @@ async function ensureCollectorState() {
|
|
|
14
14
|
return state;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
async function runCollectorOnce({ limit =
|
|
17
|
+
async function runCollectorOnce({ limit = 10 } = {}) {
|
|
18
18
|
const state = await ensureCollectorState();
|
|
19
19
|
await heartbeatCollector({
|
|
20
20
|
collectorId: state.collectorId,
|
|
@@ -42,8 +42,8 @@ async function runCollectorOnce({ limit = 5 } = {}) {
|
|
|
42
42
|
});
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
async function runCollectorLoop({ limit =
|
|
46
|
-
const safePollMs = Math.max(
|
|
45
|
+
async function runCollectorLoop({ limit = 10, pollSeconds = 180 } = {}) {
|
|
46
|
+
const safePollMs = Math.max(30, Number(pollSeconds) || 180) * 1000;
|
|
47
47
|
|
|
48
48
|
while (true) {
|
|
49
49
|
const started = Date.now();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { runCollectorLoop } = require('./client');
|
|
4
|
+
|
|
5
|
+
function parseArg(name, fallback) {
|
|
6
|
+
const idx = process.argv.indexOf(name);
|
|
7
|
+
if (idx === -1) return fallback;
|
|
8
|
+
const value = Number(process.argv[idx + 1]);
|
|
9
|
+
if (!Number.isFinite(value)) return fallback;
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function main() {
|
|
14
|
+
const limit = Math.max(1, Math.round(parseArg('--limit', Number(process.env.COLLECTOR_LIMIT) || 10)));
|
|
15
|
+
const pollSeconds = Math.max(30, Math.round(parseArg('--poll-seconds', Number(process.env.COLLECTOR_POLL_SECONDS) || 180)));
|
|
16
|
+
await runCollectorLoop({ limit, pollSeconds });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
main().catch((err) => {
|
|
20
|
+
console.error(`[collector-daemon] fatal=${err.message}`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
});
|
package/src/collector/state.js
CHANGED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const { getUserId } = require('../user-context');
|
|
2
|
+
const {
|
|
3
|
+
ensureBackgroundOn,
|
|
4
|
+
ensureBackgroundOff,
|
|
5
|
+
getBackgroundStatus,
|
|
6
|
+
resolveCollectorLimit,
|
|
7
|
+
resolvePollSeconds,
|
|
8
|
+
} = require('../background/service');
|
|
9
|
+
|
|
10
|
+
module.exports = function (program) {
|
|
11
|
+
program
|
|
12
|
+
.command('background <action>')
|
|
13
|
+
.description('Manage automatic background collector service (on|off|status)')
|
|
14
|
+
.option('--poll-seconds <n>', 'Polling interval in seconds (default: 180)')
|
|
15
|
+
.option('--limit <n>', 'Max jobs per poll (default: 10)')
|
|
16
|
+
.option('--json', 'Output as JSON')
|
|
17
|
+
.action(async (action, opts) => {
|
|
18
|
+
const normalizedAction = String(action || '').trim().toLowerCase();
|
|
19
|
+
const userId = getUserId();
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
if (normalizedAction === 'on') {
|
|
23
|
+
const report = await ensureBackgroundOn({
|
|
24
|
+
userId,
|
|
25
|
+
pollSeconds: opts.pollSeconds,
|
|
26
|
+
limit: opts.limit,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (opts.json) {
|
|
30
|
+
console.log(JSON.stringify(report));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!report.supported) {
|
|
35
|
+
console.log(`Background service unsupported on platform: ${process.platform}`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const pollSeconds = resolvePollSeconds(opts.pollSeconds);
|
|
40
|
+
const limit = resolveCollectorLimit(opts.limit);
|
|
41
|
+
console.log(`Background collector ON (poll=${pollSeconds}s limit=${limit})`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (normalizedAction === 'off') {
|
|
46
|
+
const report = await ensureBackgroundOff({ userId });
|
|
47
|
+
|
|
48
|
+
if (opts.json) {
|
|
49
|
+
console.log(JSON.stringify(report));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!report.supported) {
|
|
54
|
+
console.log(`Background service unsupported on platform: ${process.platform}`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log('Background collector OFF');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (normalizedAction === 'status') {
|
|
63
|
+
const report = await getBackgroundStatus({ userId });
|
|
64
|
+
if (opts.json) {
|
|
65
|
+
console.log(JSON.stringify(report));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!report.supported) {
|
|
70
|
+
console.log(`Background service unsupported on platform: ${process.platform}`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log(`Background collector: ${report.service.running ? 'running' : 'stopped'}`);
|
|
75
|
+
if (report.local?.background?.pollSeconds) {
|
|
76
|
+
console.log(`Poll interval: ${report.local.background.pollSeconds}s`);
|
|
77
|
+
}
|
|
78
|
+
if (report.local?.background?.limit) {
|
|
79
|
+
console.log(`Poll limit: ${report.local.background.limit}`);
|
|
80
|
+
}
|
|
81
|
+
if (report.local?.collectorId) {
|
|
82
|
+
console.log(`Collector ID: ${report.local.collectorId}`);
|
|
83
|
+
}
|
|
84
|
+
if (report.remote?.last_seen_at) {
|
|
85
|
+
console.log(`Last heartbeat: ${report.remote.last_seen_at}`);
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.error('Unknown background action. Use: on, off, status.');
|
|
91
|
+
process.exit(1);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.error(`Error: ${err.message}`);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
};
|
|
@@ -23,16 +23,16 @@ function getDefaultCollectorName() {
|
|
|
23
23
|
module.exports = function (program) {
|
|
24
24
|
program
|
|
25
25
|
.command('collector <action>')
|
|
26
|
-
.description('Manage local collector
|
|
26
|
+
.description('Manage local collector process (advanced/debug)')
|
|
27
27
|
.option('--name <name>', 'Collector name override')
|
|
28
|
-
.option('--limit <n>', 'Max jobs per loop/once run', '
|
|
29
|
-
.option('--poll-seconds <n>', 'Polling interval for start loop', '
|
|
28
|
+
.option('--limit <n>', 'Max jobs per loop/once run', '10')
|
|
29
|
+
.option('--poll-seconds <n>', 'Polling interval for start loop', '180')
|
|
30
30
|
.option('--json', 'Output as JSON')
|
|
31
31
|
.action(async (action, opts) => {
|
|
32
32
|
const normalizedAction = String(action || '').trim().toLowerCase();
|
|
33
33
|
const userId = getUserId();
|
|
34
|
-
const limit = Math.max(1, Number(opts.limit) ||
|
|
35
|
-
const pollSeconds = Math.max(
|
|
34
|
+
const limit = Math.max(1, Number(opts.limit) || 10);
|
|
35
|
+
const pollSeconds = Math.max(30, Number(opts.pollSeconds) || 180);
|
|
36
36
|
|
|
37
37
|
try {
|
|
38
38
|
if (normalizedAction === 'enable') {
|
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
upsertUserSubscription,
|
|
11
11
|
} = require('../db');
|
|
12
12
|
const { getUserId } = require('../user-context');
|
|
13
|
+
const { maybeEnsureBackgroundOn } = require('../background/service');
|
|
13
14
|
const { normalizeTier, computeNextScrapeAt } = require('../tiering');
|
|
14
15
|
|
|
15
16
|
module.exports = function (program) {
|
|
@@ -95,6 +96,7 @@ module.exports = function (program) {
|
|
|
95
96
|
tierPref: selectedTier,
|
|
96
97
|
isActive: true,
|
|
97
98
|
});
|
|
99
|
+
const background = await maybeEnsureBackgroundOn({ userId });
|
|
98
100
|
|
|
99
101
|
if (opts.json) {
|
|
100
102
|
console.log(JSON.stringify({
|
|
@@ -111,6 +113,7 @@ module.exports = function (program) {
|
|
|
111
113
|
},
|
|
112
114
|
initialPrice: initial?.price?.numeric || null,
|
|
113
115
|
initialCurrency: initial?.price?.currency || null,
|
|
116
|
+
background,
|
|
114
117
|
}));
|
|
115
118
|
return;
|
|
116
119
|
}
|
|
@@ -118,6 +121,11 @@ module.exports = function (program) {
|
|
|
118
121
|
console.log(`Subscribed: ${product.asin} (${product.title})`);
|
|
119
122
|
console.log(`User: ${userId}`);
|
|
120
123
|
console.log(`Tier pref: ${subscription.tier_pref || 'default'}`);
|
|
124
|
+
if (background.running) {
|
|
125
|
+
console.log(`Background: running (${background.pollSeconds || 180}s poll)`);
|
|
126
|
+
} else if (background.attempted && background.error) {
|
|
127
|
+
console.log(`Background: setup failed (${background.error})`);
|
|
128
|
+
}
|
|
121
129
|
} catch (err) {
|
|
122
130
|
console.error(`Error: ${err.message}`);
|
|
123
131
|
process.exit(1);
|
package/src/commands/track.js
CHANGED
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
upsertProductLatestPrice,
|
|
10
10
|
} = require('../db');
|
|
11
11
|
const { getUserId } = require('../user-context');
|
|
12
|
+
const { maybeEnsureBackgroundOn } = require('../background/service');
|
|
12
13
|
const { normalizeTier, computeNextScrapeAt } = require('../tiering');
|
|
13
14
|
|
|
14
15
|
module.exports = function (program) {
|
|
@@ -74,6 +75,7 @@ module.exports = function (program) {
|
|
|
74
75
|
const nextTier = normalizeTier(product.tier, selectedTier || 'daily');
|
|
75
76
|
const userId = getUserId();
|
|
76
77
|
let subscription = null;
|
|
78
|
+
const background = await maybeEnsureBackgroundOn({ userId });
|
|
77
79
|
try {
|
|
78
80
|
await updateProductById(product.id, {
|
|
79
81
|
last_price: result.price.numeric,
|
|
@@ -108,12 +110,18 @@ module.exports = function (program) {
|
|
|
108
110
|
active: opts.inactive ? false : (product.is_active ?? true),
|
|
109
111
|
userId,
|
|
110
112
|
subscribed: Boolean(subscription),
|
|
113
|
+
background,
|
|
111
114
|
}));
|
|
112
115
|
} else {
|
|
113
116
|
console.log(`Tracking: ${result.title}`);
|
|
114
117
|
console.log(`ASIN: ${result.asin}`);
|
|
115
118
|
console.log(`Price: ${result.priceRaw}`);
|
|
116
119
|
console.log(`Tier: ${nextTier}`);
|
|
120
|
+
if (background.running) {
|
|
121
|
+
console.log(`Background collector: running (${background.pollSeconds || 180}s poll)`);
|
|
122
|
+
} else if (background.attempted && background.error) {
|
|
123
|
+
console.log(`Background collector: setup failed (${background.error})`);
|
|
124
|
+
}
|
|
117
125
|
console.log(`Saved to Supabase.`);
|
|
118
126
|
}
|
|
119
127
|
} catch (err) {
|