imprint-mcp 0.2.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/CHANGELOG.md +168 -0
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/examples/discoverandgo/README.md +57 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/cron.json +8 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/index.ts +89 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/workflow.json +39 -0
- package/examples/echo/README.md +37 -0
- package/examples/echo/echo_test/index.ts +31 -0
- package/examples/google-flights/search_google_flights/index.ts +101 -0
- package/examples/google-flights/search_google_flights/parser.test.ts +140 -0
- package/examples/google-flights/search_google_flights/parser.ts +189 -0
- package/examples/google-flights/search_google_flights/playbook.yaml +130 -0
- package/examples/google-flights/search_google_flights/workflow.json +48 -0
- package/examples/google-hotels/search_google_hotels/index.ts +194 -0
- package/examples/google-hotels/search_google_hotels/parser.test.ts +168 -0
- package/examples/google-hotels/search_google_hotels/parser.ts +330 -0
- package/examples/google-hotels/search_google_hotels/playbook.yaml +125 -0
- package/examples/google-hotels/search_google_hotels/workflow.json +111 -0
- package/examples/namecheap-domains/search_namecheap_domains/index.ts +144 -0
- package/examples/namecheap-domains/search_namecheap_domains/parser.ts +380 -0
- package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +50 -0
- package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +136 -0
- package/examples/namecheap-domains/search_namecheap_domains/workflow.json +97 -0
- package/examples/southwest/README.md +81 -0
- package/examples/southwest/search_southwest_flights/backends.json +23 -0
- package/examples/southwest/search_southwest_flights/cron.json +19 -0
- package/examples/southwest/search_southwest_flights/index.ts +110 -0
- package/examples/southwest/search_southwest_flights/playbook.yaml +46 -0
- package/examples/southwest/search_southwest_flights/workflow.json +54 -0
- package/package.json +78 -0
- package/prompts/compile-agent.md +580 -0
- package/prompts/intent-detection.md +198 -0
- package/prompts/playbook-compilation.md +279 -0
- package/prompts/request-triage.md +74 -0
- package/prompts/tool-candidate-detection.md +104 -0
- package/src/cli.ts +1287 -0
- package/src/imprint/agent.ts +468 -0
- package/src/imprint/app-api-hosts.ts +53 -0
- package/src/imprint/backend-ladder.ts +568 -0
- package/src/imprint/check.ts +136 -0
- package/src/imprint/chromium.ts +211 -0
- package/src/imprint/claude-cli-compile.ts +640 -0
- package/src/imprint/cli-credential.ts +394 -0
- package/src/imprint/codex-cli-compile.ts +712 -0
- package/src/imprint/compile-agent-types.ts +40 -0
- package/src/imprint/compile-agent.ts +404 -0
- package/src/imprint/compile-tools.ts +1389 -0
- package/src/imprint/compile.ts +720 -0
- package/src/imprint/cookie-jar.ts +246 -0
- package/src/imprint/credential-bundle.ts +195 -0
- package/src/imprint/credential-extract.ts +290 -0
- package/src/imprint/credential-store.ts +707 -0
- package/src/imprint/cron.ts +312 -0
- package/src/imprint/doctor.ts +223 -0
- package/src/imprint/emit.ts +154 -0
- package/src/imprint/etld.ts +134 -0
- package/src/imprint/freeform-redact.ts +216 -0
- package/src/imprint/inject-listener.ts +137 -0
- package/src/imprint/install.ts +795 -0
- package/src/imprint/integrations.ts +385 -0
- package/src/imprint/is-compiled.ts +2 -0
- package/src/imprint/json-path.ts +100 -0
- package/src/imprint/llm.ts +998 -0
- package/src/imprint/load-json.ts +54 -0
- package/src/imprint/log.ts +33 -0
- package/src/imprint/login.ts +166 -0
- package/src/imprint/mcp-compile-server.ts +282 -0
- package/src/imprint/mcp-maintenance.ts +1790 -0
- package/src/imprint/mcp-server.ts +350 -0
- package/src/imprint/multi-progress.ts +69 -0
- package/src/imprint/notify.ts +155 -0
- package/src/imprint/paths.ts +64 -0
- package/src/imprint/playbook-parser.ts +21 -0
- package/src/imprint/playbook-runner.ts +465 -0
- package/src/imprint/probe-backends.ts +251 -0
- package/src/imprint/progress.ts +28 -0
- package/src/imprint/record.ts +470 -0
- package/src/imprint/redact.ts +550 -0
- package/src/imprint/replay-capture.ts +387 -0
- package/src/imprint/request-context.ts +66 -0
- package/src/imprint/runtime-link.ts +73 -0
- package/src/imprint/runtime.ts +942 -0
- package/src/imprint/sensitive-keys.ts +156 -0
- package/src/imprint/session-diff.ts +409 -0
- package/src/imprint/session-merge.ts +198 -0
- package/src/imprint/session-writer.ts +149 -0
- package/src/imprint/sites.ts +27 -0
- package/src/imprint/stealth-fetch.ts +434 -0
- package/src/imprint/teach-state.ts +235 -0
- package/src/imprint/teach.ts +2120 -0
- package/src/imprint/tool-candidates.ts +423 -0
- package/src/imprint/tool-loader.ts +186 -0
- package/src/imprint/tool-selection.ts +70 -0
- package/src/imprint/tracing.ts +508 -0
- package/src/imprint/types.ts +472 -0
- package/src/imprint/version.ts +21 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request transform for Namecheap RTB endpoints.
|
|
3
|
+
*
|
|
4
|
+
* The namecheap.com domain-search UI signs every request to its real-time
|
|
5
|
+
* bidding APIs (rtb.namecheapapi.com, etc.) with an `rcs` query param
|
|
6
|
+
* computed by client-side JavaScript. The signature is:
|
|
7
|
+
* 1. Build a canonical string:
|
|
8
|
+
* "<secret> <nonce32hex> <METHOD> <pathname> <key1>=<encodedValue1>&<key2>=..."
|
|
9
|
+
* where query pairs come from the URL's raw search string (encoded values),
|
|
10
|
+
* excluding any existing `rcs` key, sorted alphabetically by key. The
|
|
11
|
+
* values are then re-encoded with encodeURIComponent (potentially
|
|
12
|
+
* double-encoding), per the original JS implementation.
|
|
13
|
+
* 2. Compute CRC32 (signed 32-bit) of that string.
|
|
14
|
+
* 3. JSON.stringify({val: <crc>, n: <nonce>}).
|
|
15
|
+
* 4. XOR each character of the JSON with 73, then base64 the resulting
|
|
16
|
+
* binary string. That value, URL-encoded, becomes the rcs param.
|
|
17
|
+
*
|
|
18
|
+
* The two static "secrets" (one for *.namecheapapi.com, one for *.revved.com)
|
|
19
|
+
* are app-level constants embedded in the public domain-search bundle — not
|
|
20
|
+
* per-user secrets — and gate access to real availability/pricing data.
|
|
21
|
+
* Without a valid rcs the API returns sentinel "domain.com" mock data.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const NC_SECRET = '815e7ef93be85bebe5959f6f72d7e542';
|
|
25
|
+
const REVVED_SECRET = '8f6c7d5691eebd3b5090dc6b06755d58';
|
|
26
|
+
|
|
27
|
+
const NC_HOST_RE =
|
|
28
|
+
/(sb[.-])?(rtb|aftermarket|premiums|pricerequest|business-lookup|domain-suggestion)?\.namecheapapi\.com$/;
|
|
29
|
+
const REVVED_HOST_RE = /(sb-)?domains?\.revved\.com$/;
|
|
30
|
+
|
|
31
|
+
// CRC32 lookup table (matching the `crc-32` npm module v1.2.2 used by the site).
|
|
32
|
+
const CRC32_TABLE: Int32Array = (() => {
|
|
33
|
+
const t = new Int32Array(256);
|
|
34
|
+
for (let n = 0; n < 256; n++) {
|
|
35
|
+
let e = n;
|
|
36
|
+
for (let k = 0; k < 8; k++) {
|
|
37
|
+
e = e & 1 ? -306674912 ^ (e >>> 1) : e >>> 1;
|
|
38
|
+
}
|
|
39
|
+
t[n] = e;
|
|
40
|
+
}
|
|
41
|
+
return t;
|
|
42
|
+
})();
|
|
43
|
+
|
|
44
|
+
function crc32Str(str: string, seed = 0): number {
|
|
45
|
+
let r = ~seed;
|
|
46
|
+
for (let a = 0; a < str.length; a++) {
|
|
47
|
+
let i = str.charCodeAt(a);
|
|
48
|
+
if (i < 128) {
|
|
49
|
+
r = (r >>> 8) ^ CRC32_TABLE[(r ^ i) & 255]!;
|
|
50
|
+
} else if (i < 2048) {
|
|
51
|
+
r = (r >>> 8) ^ CRC32_TABLE[(r ^ (192 | ((i >> 6) & 31))) & 255]!;
|
|
52
|
+
r = (r >>> 8) ^ CRC32_TABLE[(r ^ (128 | (63 & i))) & 255]!;
|
|
53
|
+
} else if (i >= 55296 && i < 57344) {
|
|
54
|
+
i = 64 + (1023 & i);
|
|
55
|
+
const s = 1023 & str.charCodeAt(++a);
|
|
56
|
+
r = (r >>> 8) ^ CRC32_TABLE[(r ^ (240 | ((i >> 8) & 7))) & 255]!;
|
|
57
|
+
r = (r >>> 8) ^ CRC32_TABLE[(r ^ (128 | ((i >> 2) & 63))) & 255]!;
|
|
58
|
+
r = (r >>> 8) ^ CRC32_TABLE[(r ^ (128 | ((s >> 6) & 15) | ((3 & i) << 4))) & 255]!;
|
|
59
|
+
r = (r >>> 8) ^ CRC32_TABLE[(r ^ (128 | (63 & s))) & 255]!;
|
|
60
|
+
} else {
|
|
61
|
+
r = (r >>> 8) ^ CRC32_TABLE[(r ^ (224 | ((i >> 12) & 15))) & 255]!;
|
|
62
|
+
r = (r >>> 8) ^ CRC32_TABLE[(r ^ (128 | ((i >> 6) & 63))) & 255]!;
|
|
63
|
+
r = (r >>> 8) ^ CRC32_TABLE[(r ^ (128 | (63 & i))) & 255]!;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return ~r;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function randomNonce32Hex(): string {
|
|
70
|
+
let s = '';
|
|
71
|
+
for (let i = 0; i < 32; i++) s += Math.floor(Math.random() * 16).toString(16);
|
|
72
|
+
return s;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function chooseSecret(host: string): string | null {
|
|
76
|
+
if (REVVED_HOST_RE.test(host)) return REVVED_SECRET;
|
|
77
|
+
if (NC_HOST_RE.test(host)) return NC_SECRET;
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function transform(method: string, urlStr: string, responses?: unknown[]): string {
|
|
82
|
+
let url: URL;
|
|
83
|
+
try {
|
|
84
|
+
url = new URL(urlStr);
|
|
85
|
+
} catch {
|
|
86
|
+
return urlStr;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// domainStatus placeholder: replace __PLACEHOLDER__ with actual domain
|
|
90
|
+
// names extracted from the search response (responses[1]).
|
|
91
|
+
// Use string replacement instead of searchParams.set to avoid encoding commas.
|
|
92
|
+
if (url.pathname === '/v1/domainStatus' && urlStr.includes('__PLACEHOLDER__') && responses) {
|
|
93
|
+
const search = responses[1] as { picks?: Array<{ domain?: string }>; ranks?: Array<{ domain?: string }>; exact_match?: { domain?: string } } | undefined;
|
|
94
|
+
const domains = new Set<string>();
|
|
95
|
+
if (search?.exact_match?.domain) domains.add(search.exact_match.domain);
|
|
96
|
+
for (const p of search?.picks ?? []) { if (p?.domain) domains.add(p.domain); }
|
|
97
|
+
for (const r of search?.ranks ?? []) { if (r?.domain) domains.add(r.domain); }
|
|
98
|
+
urlStr = urlStr.replace('__PLACEHOLDER__', [...domains].join(','));
|
|
99
|
+
try { url = new URL(urlStr); } catch { return urlStr; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const secret = chooseSecret(url.host);
|
|
103
|
+
if (!secret) return urlStr;
|
|
104
|
+
|
|
105
|
+
const nonce = randomNonce32Hex();
|
|
106
|
+
let canonical = `${secret} ${nonce} ${method.toUpperCase()} ${url.pathname} `;
|
|
107
|
+
|
|
108
|
+
const pairs: Array<[string, string]> = [];
|
|
109
|
+
if (url.search && url.search.startsWith('?')) {
|
|
110
|
+
for (const part of url.search.slice(1).split('&')) {
|
|
111
|
+
const idx = part.indexOf('=');
|
|
112
|
+
if (idx === -1) continue;
|
|
113
|
+
const k = part.slice(0, idx);
|
|
114
|
+
const v = part.slice(idx + 1);
|
|
115
|
+
if (k !== 'rcs') pairs.push([k, v]);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
pairs.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
119
|
+
canonical += pairs.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
|
|
120
|
+
|
|
121
|
+
const val = crc32Str(canonical, 0);
|
|
122
|
+
const payload = JSON.stringify({ val, n: nonce });
|
|
123
|
+
|
|
124
|
+
let xored = '';
|
|
125
|
+
for (let i = 0; i < payload.length; i++) {
|
|
126
|
+
xored += String.fromCharCode(73 ^ payload.charCodeAt(i));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Buffer is available in Bun/Node; encode the XOR'd binary string as base64.
|
|
130
|
+
const rcs = Buffer.from(xored, 'binary').toString('base64');
|
|
131
|
+
|
|
132
|
+
// Strip any pre-existing rcs param the caller may have placed in the URL.
|
|
133
|
+
const filtered = pairs.map(([k, v]) => `${k}=${v}`).join('&');
|
|
134
|
+
const newSearch = filtered ? `?${filtered}&rcs=${encodeURIComponent(rcs)}` : `?rcs=${encodeURIComponent(rcs)}`;
|
|
135
|
+
return `${url.origin}${url.pathname}${newSearch}${url.hash}`;
|
|
136
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
{
|
|
2
|
+
"toolName": "search_namecheap_domains",
|
|
3
|
+
"intent": {
|
|
4
|
+
"description": "Search Namecheap for available domains matching a query, returning the exact-match availability + pricing, suggested domain picks across many TLDs, the TLD catalog, and Spaceship/brandstore aftermarket marketplace listings.",
|
|
5
|
+
"userSaid": "i searched for imprint on namecheap. njow there are a lot of filters, by default i am on \"Popular\". there were about 30 filters. i cycled through all of them"
|
|
6
|
+
},
|
|
7
|
+
"parameters": [
|
|
8
|
+
{
|
|
9
|
+
"name": "query",
|
|
10
|
+
"type": "string",
|
|
11
|
+
"description": "The SLD (second-level-domain) or search term to look up (e.g. 'imprint').",
|
|
12
|
+
"default": "imprint"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "category",
|
|
16
|
+
"type": "string",
|
|
17
|
+
"description": "Optional TLD category filter to narrow returned suggestions. One of the CategoryName values exposed by the TLD catalog: 'popular', 'international', 'new', '088domains'. Leave empty for unfiltered results.",
|
|
18
|
+
"default": ""
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"site": "namecheap-domains",
|
|
22
|
+
"requests": [
|
|
23
|
+
{
|
|
24
|
+
"method": "GET",
|
|
25
|
+
"url": "https://www.namecheap.com/domains/tlds.ashx",
|
|
26
|
+
"headers": {
|
|
27
|
+
"Accept": "application/json, text/plain, */*",
|
|
28
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
29
|
+
"Referer": "https://www.namecheap.com/domains/registration/results/?domain=${param.query}",
|
|
30
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
|
|
31
|
+
"sec-ch-ua": "\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"",
|
|
32
|
+
"sec-ch-ua-mobile": "?0",
|
|
33
|
+
"sec-ch-ua-platform": "\"macOS\""
|
|
34
|
+
},
|
|
35
|
+
"effect": "safe"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"method": "GET",
|
|
39
|
+
"url": "https://rtb.namecheapapi.com/api/search/${param.query}?session_id=1000000000000&search=false",
|
|
40
|
+
"headers": {
|
|
41
|
+
"Accept": "application/json, text/plain, */*",
|
|
42
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
43
|
+
"Origin": "https://www.namecheap.com",
|
|
44
|
+
"Referer": "https://www.namecheap.com/",
|
|
45
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
|
|
46
|
+
"sec-ch-ua": "\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"",
|
|
47
|
+
"sec-ch-ua-mobile": "?0",
|
|
48
|
+
"sec-ch-ua-platform": "\"macOS\""
|
|
49
|
+
},
|
|
50
|
+
"effect": "safe"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"method": "GET",
|
|
54
|
+
"url": "https://rtb.namecheapapi.com/api/picks/${param.query}?session_id=1000000000000",
|
|
55
|
+
"headers": {
|
|
56
|
+
"Accept": "application/json, text/plain, */*",
|
|
57
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
58
|
+
"Origin": "https://www.namecheap.com",
|
|
59
|
+
"Referer": "https://www.namecheap.com/",
|
|
60
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
|
|
61
|
+
"sec-ch-ua": "\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"",
|
|
62
|
+
"sec-ch-ua-mobile": "?0",
|
|
63
|
+
"sec-ch-ua-platform": "\"macOS\""
|
|
64
|
+
},
|
|
65
|
+
"effect": "safe"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"method": "POST",
|
|
69
|
+
"url": "https://www.namecheap.com/api/v1/ncpl/domainsearchgateway/domain/readBySld",
|
|
70
|
+
"headers": {
|
|
71
|
+
"Accept": "application/json, text/plain, */*",
|
|
72
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
73
|
+
"Content-Type": "application/json; charset=UTF-8",
|
|
74
|
+
"Origin": "https://www.namecheap.com",
|
|
75
|
+
"Referer": "https://www.namecheap.com/domains/registration/results/?domain=${param.query}",
|
|
76
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
|
|
77
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
78
|
+
"sec-ch-ua": "\"Chromium\";v=\"147\", \"Not.A/Brand\";v=\"8\"",
|
|
79
|
+
"sec-ch-ua-mobile": "?0",
|
|
80
|
+
"sec-ch-ua-platform": "\"macOS\""
|
|
81
|
+
},
|
|
82
|
+
"body": "{\"request\":{\"sld\":\"${param.query}\",\"sources\":[\"sellerhub\",\"brandstore\"]}}",
|
|
83
|
+
"effect": "safe"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"method": "GET",
|
|
87
|
+
"url": "https://domains.revved.com/v1/domainStatus?whois=true&domains=__PLACEHOLDER__",
|
|
88
|
+
"headers": {
|
|
89
|
+
"Accept": "application/json, text/plain, */*",
|
|
90
|
+
"Referer": "https://www.namecheap.com/"
|
|
91
|
+
},
|
|
92
|
+
"effect": "safe"
|
|
93
|
+
}
|
|
94
|
+
],
|
|
95
|
+
"parserModule": "./parser.ts",
|
|
96
|
+
"requestTransformModule": "./request-transform.ts"
|
|
97
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Southwest fare-drop watcher
|
|
2
|
+
|
|
3
|
+
> Watch a Southwest route, push when the lowest fare drops below your threshold. Defeats Akamai bot detection via `stealth-fetch`.
|
|
4
|
+
|
|
5
|
+
## What this shows off
|
|
6
|
+
|
|
7
|
+
- **The full backend ladder in action.** `fetch` returns 403 (Akamai), `stealth-fetch` mints sensor tokens via a brief Playwright bootstrap then succeeds in ~10s/call.
|
|
8
|
+
- **`probe-backends` skipping the futile rung.** The cached `backends.json` orders the ladder `stealth-fetch → playbook` so cron doesn't burn 200ms on a fetch attempt every tick.
|
|
9
|
+
- **`notifyWhen: price_below`** pushing only on real drops, with the `pricePath` extracting from real Southwest response shape.
|
|
10
|
+
- **The fresh-UUID header trick** — Southwest rejects stale `X-User-Experience-ID`; stealth-fetch regenerates per call.
|
|
11
|
+
- **Multi-path `pricePath`** — `cron.json`'s notifyWhen lists both the raw API shape (when stealth-fetch wins) and the playbook's reshaped output, so the push fires regardless of which backend produced the result.
|
|
12
|
+
|
|
13
|
+
## Run it
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# One-time setup (if you haven't already)
|
|
17
|
+
bunx playwright install chromium
|
|
18
|
+
|
|
19
|
+
# Run a single tick (verifies everything still works)
|
|
20
|
+
imprint cron southwest --once
|
|
21
|
+
|
|
22
|
+
# Production: foreground daemon
|
|
23
|
+
NTFY_URL=https://ntfy.sh/your-secret-topic imprint cron southwest
|
|
24
|
+
|
|
25
|
+
# Production: OS scheduler (cron / systemd timer / launchd) — wraps --once
|
|
26
|
+
NTFY_URL=https://ntfy.sh/your-secret-topic imprint cron southwest --once
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## What you should see
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
[imprint cron] config: examples/southwest/search_southwest_flights/cron.json
|
|
33
|
+
[imprint cron] backends.json: probed 2026-05-03T22:23Z, preferred order: stealth-fetch → playbook
|
|
34
|
+
[imprint cron] tool: search_southwest_flights (5 param(s))
|
|
35
|
+
[imprint cron] schedule: 0 9 * * *
|
|
36
|
+
[imprint cron] notifyWhen: price_below
|
|
37
|
+
[imprint cron] replayBackend: auto (ladder: stealth-fetch → playbook)
|
|
38
|
+
[imprint backend] trying stealth-fetch…
|
|
39
|
+
[imprint stealth] bootstrapping…
|
|
40
|
+
[imprint stealth] bootstrapped in ~13s — 21 cookies, 6 sensor headers
|
|
41
|
+
[imprint backend] stealth-fetch: OK in ~15s
|
|
42
|
+
[imprint cron] OK in ~15s via stealth-fetch: {"data":{"searchResults":{...,"value":"108.40"}}}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Real Southwest data, real $108.40 lowest WGA fare. Bootstrap is one-time per process; subsequent ticks reuse the stealth-fetch session.
|
|
46
|
+
|
|
47
|
+
## Files
|
|
48
|
+
|
|
49
|
+
| File | What |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `~/.imprint/southwest/sessions/<ts>.{jsonl,json}` | Raw recording (local only — may contain cookies) |
|
|
52
|
+
| `~/.imprint/southwest/sessions/<ts>.redacted.json` | Scrubbed for LLM analysis |
|
|
53
|
+
| `workflow.json` | API workflow used by stealth-fetch backend |
|
|
54
|
+
| `index.ts` | Generated tool function (`opts.fetchImpl` is what stealth-fetch injects into) |
|
|
55
|
+
| `playbook.yaml` | DOM playbook fallback — single navigate to the URL-prefilled search + XHR result extraction |
|
|
56
|
+
| `cron.json` | Daily 9am tick; `replayBackend: "auto"`; `price_below: 99` |
|
|
57
|
+
| `backends.json` | Probe artifact — `preferredOrder: ["stealth-fetch", "playbook"]` |
|
|
58
|
+
|
|
59
|
+
## Tuning
|
|
60
|
+
|
|
61
|
+
- **Threshold**: currently `$99`. Today's lowest is $108.40 so nothing fires; lower for noisy alerts, raise to wait for a deeper drop.
|
|
62
|
+
- **Date**: polls one date (`departure_date: 2026-06-20`). Multiple dates = multiple `cron.json` files for now.
|
|
63
|
+
- **Re-probe**: re-run `imprint probe-backends southwest` if the cron starts erroring — Akamai's sensor schema changes occasionally.
|
|
64
|
+
|
|
65
|
+
## Notes (gotchas this demo handles)
|
|
66
|
+
|
|
67
|
+
These came up during bring-up; documented so the next person knows they're handled:
|
|
68
|
+
|
|
69
|
+
- **Hidden duplicate elements.** Southwest renders both a custom dropdown and a hidden native `<select>`. Runner filters to visible.
|
|
70
|
+
- **Wrapper intercepts pointer events.** Clickable `<strong>` inside `role=checkbox`. Runner retries with `force: true`.
|
|
71
|
+
- **Date input is non-typeable.** URL-prefilled navigation sidesteps the calendar widget.
|
|
72
|
+
- **Vanilla Playwright gets a 403.** `navigator.webdriver` is the tell. Stealth plugin patches it.
|
|
73
|
+
- **Token GC race.** Playwright GCs response bodies aggressively. Runner reads inside the response handler and drains pending text() promises before extracting.
|
|
74
|
+
- **`networkidle` hangs on SPAs.** Persistent connections never go idle. Runner uses `domcontentloaded` + the explicit `wait_for`.
|
|
75
|
+
- **Stale `X-User-Experience-ID`.** stealth-fetch regenerates a fresh UUID per call AND auto-injects if the workflow dropped it.
|
|
76
|
+
|
|
77
|
+
## Not in this demo
|
|
78
|
+
|
|
79
|
+
- Auto-booking the cheaper flight on a drop (read-only watcher).
|
|
80
|
+
- Multi-date sweep (one `cron.json` per date).
|
|
81
|
+
- Hosted execution (run foreground or wire to your OS scheduler).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"probedAt": "2026-05-03T22:23:26.619Z",
|
|
3
|
+
"imprintVersion": "0.1.0",
|
|
4
|
+
"preferredOrder": [
|
|
5
|
+
"stealth-fetch",
|
|
6
|
+
"playbook"
|
|
7
|
+
],
|
|
8
|
+
"results": {
|
|
9
|
+
"fetch": {
|
|
10
|
+
"outcome": "forbidden",
|
|
11
|
+
"durationMs": 579,
|
|
12
|
+
"detail": "Request 0 returned 403: {\n \"code\": 403050700\n}"
|
|
13
|
+
},
|
|
14
|
+
"stealth-fetch": {
|
|
15
|
+
"outcome": "ok",
|
|
16
|
+
"durationMs": 12419
|
|
17
|
+
},
|
|
18
|
+
"playbook": {
|
|
19
|
+
"outcome": "ok",
|
|
20
|
+
"durationMs": 13597
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schedule": "0 9 * * *",
|
|
3
|
+
"replayBackend": "auto",
|
|
4
|
+
"params": {
|
|
5
|
+
"origin_airport_code": "SJC",
|
|
6
|
+
"destination_airport_code": "SAN",
|
|
7
|
+
"departure_date": "2026-06-20",
|
|
8
|
+
"adult_passengers_count": 1,
|
|
9
|
+
"fare_type": "USD"
|
|
10
|
+
},
|
|
11
|
+
"notifyWhen": {
|
|
12
|
+
"type": "price_below",
|
|
13
|
+
"threshold": 99,
|
|
14
|
+
"pricePath": [
|
|
15
|
+
"data.searchResults.airProducts[].lowestFare.value",
|
|
16
|
+
"prices[]"
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED by `imprint emit` — DO NOT EDIT BY HAND.
|
|
3
|
+
*
|
|
4
|
+
* Tool: search_southwest_flights
|
|
5
|
+
* Site: southwest
|
|
6
|
+
* Intent: Search for one-way Southwest Airlines flights between two airports on a given departure date, returning available flights and fares.
|
|
7
|
+
*
|
|
8
|
+
* To regenerate: imprint emit ~/.imprint/southwest/search_southwest_flights/workflow.json --force
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import {
|
|
14
|
+
executeWorkflow,
|
|
15
|
+
type CredentialStore,
|
|
16
|
+
} from 'imprint/runtime';
|
|
17
|
+
import type { ToolResult, Workflow } from 'imprint/types';
|
|
18
|
+
|
|
19
|
+
const WORKFLOW: Workflow = {
|
|
20
|
+
"toolName": "search_southwest_flights",
|
|
21
|
+
"intent": {
|
|
22
|
+
"description": "Search for one-way Southwest Airlines flights between two airports on a given departure date, returning available flights and fares.",
|
|
23
|
+
"userSaid": "i just specified the departure and arrival airports (SJC and SAN) i set teh depart date now i changed to one way trip only now i clicked search now i clicked hte low fare calendar i clicked 26 june from the low fare calendar now i clicked continue to flight times on the low fare calendar, and now it's showing me the flight prices for 26 june"
|
|
24
|
+
},
|
|
25
|
+
"parameters": [
|
|
26
|
+
{
|
|
27
|
+
"name": "origin_airport_code",
|
|
28
|
+
"type": "string",
|
|
29
|
+
"description": "IATA code for the departure airport (e.g. SJC)",
|
|
30
|
+
"default": "SJC"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "destination_airport_code",
|
|
34
|
+
"type": "string",
|
|
35
|
+
"description": "IATA code for the arrival airport (e.g. SAN)",
|
|
36
|
+
"default": "SAN"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "departure_date",
|
|
40
|
+
"type": "string",
|
|
41
|
+
"description": "Departure date in YYYY-MM-DD format",
|
|
42
|
+
"default": "2026-06-23"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "adult_passengers_count",
|
|
46
|
+
"type": "number",
|
|
47
|
+
"description": "Number of adult passengers",
|
|
48
|
+
"default": 1
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "fare_type",
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "Fare currency type: USD for dollars, POINTS for Rapid Rewards points",
|
|
54
|
+
"default": "USD"
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
"requests": [
|
|
58
|
+
{
|
|
59
|
+
"method": "POST",
|
|
60
|
+
"url": "https://www.southwest.com/api/air-booking/v1/air-booking/page/air/booking/shopping",
|
|
61
|
+
"headers": {
|
|
62
|
+
"Content-Type": "application/json",
|
|
63
|
+
"Accept": "application/json, text/javascript, */*; q=0.01",
|
|
64
|
+
"X-API-Key": "${env.SOUTHWEST_API_KEY}",
|
|
65
|
+
"X-App-ID": "air-booking",
|
|
66
|
+
"X-Channel-ID": "southwest"
|
|
67
|
+
},
|
|
68
|
+
"body": "{\"adultPassengersCount\":\"${param.adult_passengers_count}\",\"adultsCount\":\"${param.adult_passengers_count}\",\"departureDate\":\"${param.departure_date}\",\"departureTimeOfDay\":\"ALL_DAY\",\"destinationAirportCode\":\"${param.destination_airport_code}\",\"fareType\":\"${param.fare_type}\",\"int\":\"HOMEQBOMAIR\",\"originationAirportCode\":\"${param.origin_airport_code}\",\"passengerType\":\"ADULT\",\"promoCode\":\"\",\"returnDate\":\"\",\"returnTimeOfDay\":\"ALL_DAY\",\"tripType\":\"oneway\",\"application\":\"air-booking\",\"site\":\"southwest\"}"
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
"site": "southwest"
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export interface SearchSouthwestFlightsInput {
|
|
75
|
+
/** IATA code for the departure airport (e.g. SJC) */
|
|
76
|
+
origin_airport_code?: string;
|
|
77
|
+
/** IATA code for the arrival airport (e.g. SAN) */
|
|
78
|
+
destination_airport_code?: string;
|
|
79
|
+
/** Departure date in YYYY-MM-DD format */
|
|
80
|
+
departure_date?: string;
|
|
81
|
+
/** Number of adult passengers */
|
|
82
|
+
adult_passengers_count?: number;
|
|
83
|
+
/** Fare currency type: USD for dollars, POINTS for Rapid Rewards points */
|
|
84
|
+
fare_type?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function searchSouthwestFlights(
|
|
88
|
+
input: SearchSouthwestFlightsInput,
|
|
89
|
+
opts: { credentials?: CredentialStore; fetchImpl?: typeof fetch; initialState?: Record<string, unknown> } = {},
|
|
90
|
+
): Promise<ToolResult> {
|
|
91
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
92
|
+
const params: Record<string, string | number | boolean> = {
|
|
93
|
+
origin_airport_code: input.origin_airport_code ?? "SJC",
|
|
94
|
+
destination_airport_code: input.destination_airport_code ?? "SAN",
|
|
95
|
+
departure_date: input.departure_date ?? "2026-06-23",
|
|
96
|
+
adult_passengers_count: input.adult_passengers_count ?? 1,
|
|
97
|
+
fare_type: input.fare_type ?? "USD",
|
|
98
|
+
|
|
99
|
+
};
|
|
100
|
+
return executeWorkflow({
|
|
101
|
+
workflow: WORKFLOW,
|
|
102
|
+
params,
|
|
103
|
+
credentials: opts.credentials,
|
|
104
|
+
fetchImpl: opts.fetchImpl,
|
|
105
|
+
initialState: opts.initialState,
|
|
106
|
+
workflowPath: join(__dirname, 'workflow.json'),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export { WORKFLOW };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
toolName: search_southwest_flights
|
|
2
|
+
summary: |
|
|
3
|
+
Search Southwest for one-way fares between two airports on a given date.
|
|
4
|
+
Uses the URL-prefilled search shortcut so a single navigation triggers the
|
|
5
|
+
shopping XHR — no DOM walk needed. Stealth Chromium (default in the runner)
|
|
6
|
+
defeats Akamai's bot detection that blocks naive HTTP clients.
|
|
7
|
+
parameters:
|
|
8
|
+
- name: origin_airport_code
|
|
9
|
+
type: string
|
|
10
|
+
description: IATA origin airport code, e.g. SJC
|
|
11
|
+
- name: destination_airport_code
|
|
12
|
+
type: string
|
|
13
|
+
description: IATA destination airport code, e.g. SAN
|
|
14
|
+
- name: departure_date
|
|
15
|
+
type: string
|
|
16
|
+
description: YYYY-MM-DD
|
|
17
|
+
- name: adult_passengers_count
|
|
18
|
+
type: number
|
|
19
|
+
description: Number of adult passengers
|
|
20
|
+
default: 1
|
|
21
|
+
- name: fare_type
|
|
22
|
+
type: string
|
|
23
|
+
description: "USD or POINTS"
|
|
24
|
+
default: USD
|
|
25
|
+
steps:
|
|
26
|
+
- action: navigate
|
|
27
|
+
url: https://www.southwest.com/air/booking/select-depart.html?adultsCount=${adult_passengers_count}&adultPassengersCount=${adult_passengers_count}&originationAirportCode=${origin_airport_code}&destinationAirportCode=${destination_airport_code}&departureDate=${departure_date}&departureTimeOfDay=ALL_DAY&fareType=${fare_type}&int=HOMEQBOMAIR&passengerType=ADULT&promoCode=&returnDate=&returnTimeOfDay=ALL_DAY&tripType=oneway
|
|
28
|
+
wait_for:
|
|
29
|
+
xhr: /api/air-booking/v1/air-booking/page/air/booking/shopping
|
|
30
|
+
result:
|
|
31
|
+
source: xhr
|
|
32
|
+
url_pattern: /api/air-booking/v1/air-booking/page/air/booking/shopping
|
|
33
|
+
extract: data.searchResults.airProducts[].lowestFare.value
|
|
34
|
+
return_as: prices
|
|
35
|
+
notes: |
|
|
36
|
+
Param names match workflow.json exactly (origin_airport_code etc., not the
|
|
37
|
+
friendlier origin) so cron.json's params block is shared across the
|
|
38
|
+
fetch / stealth-fetch / playbook backends and the auto-ladder can hot-swap
|
|
39
|
+
between them without rewriting params.
|
|
40
|
+
|
|
41
|
+
The original recording walked through the form (autocompletes, date picker,
|
|
42
|
+
trip-type dropdown, search submit, Low Fare Calendar drilldown). Six
|
|
43
|
+
iterations revealed Southwest's date input is non-typeable (zero input
|
|
44
|
+
events captured on #departureDate — the user clicked the input + clicked
|
|
45
|
+
a calendar cell). The URL-param shortcut sidesteps every form-fill quirk
|
|
46
|
+
in one navigation.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"toolName": "search_southwest_flights",
|
|
3
|
+
"intent": {
|
|
4
|
+
"description": "Search for one-way Southwest Airlines flights between two airports on a given departure date, returning available flights and fares.",
|
|
5
|
+
"userSaid": "i just specified the departure and arrival airports (SJC and SAN) i set teh depart date now i changed to one way trip only now i clicked search now i clicked hte low fare calendar i clicked 26 june from the low fare calendar now i clicked continue to flight times on the low fare calendar, and now it's showing me the flight prices for 26 june"
|
|
6
|
+
},
|
|
7
|
+
"parameters": [
|
|
8
|
+
{
|
|
9
|
+
"name": "origin_airport_code",
|
|
10
|
+
"type": "string",
|
|
11
|
+
"description": "IATA code for the departure airport (e.g. SJC)",
|
|
12
|
+
"default": "SJC"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "destination_airport_code",
|
|
16
|
+
"type": "string",
|
|
17
|
+
"description": "IATA code for the arrival airport (e.g. SAN)",
|
|
18
|
+
"default": "SAN"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"name": "departure_date",
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Departure date in YYYY-MM-DD format",
|
|
24
|
+
"default": "2026-06-23"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "adult_passengers_count",
|
|
28
|
+
"type": "number",
|
|
29
|
+
"description": "Number of adult passengers",
|
|
30
|
+
"default": 1
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "fare_type",
|
|
34
|
+
"type": "string",
|
|
35
|
+
"description": "Fare currency type: USD for dollars, POINTS for Rapid Rewards points",
|
|
36
|
+
"default": "USD"
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"requests": [
|
|
40
|
+
{
|
|
41
|
+
"method": "POST",
|
|
42
|
+
"url": "https://www.southwest.com/api/air-booking/v1/air-booking/page/air/booking/shopping",
|
|
43
|
+
"headers": {
|
|
44
|
+
"Content-Type": "application/json",
|
|
45
|
+
"Accept": "application/json, text/javascript, */*; q=0.01",
|
|
46
|
+
"X-API-Key": "${env.SOUTHWEST_API_KEY}",
|
|
47
|
+
"X-App-ID": "air-booking",
|
|
48
|
+
"X-Channel-ID": "southwest"
|
|
49
|
+
},
|
|
50
|
+
"body": "{\"adultPassengersCount\":\"${param.adult_passengers_count}\",\"adultsCount\":\"${param.adult_passengers_count}\",\"departureDate\":\"${param.departure_date}\",\"departureTimeOfDay\":\"ALL_DAY\",\"destinationAirportCode\":\"${param.destination_airport_code}\",\"fareType\":\"${param.fare_type}\",\"int\":\"HOMEQBOMAIR\",\"originationAirportCode\":\"${param.origin_airport_code}\",\"passengerType\":\"ADULT\",\"promoCode\":\"\",\"returnDate\":\"\",\"returnTimeOfDay\":\"ALL_DAY\",\"tripType\":\"oneway\",\"application\":\"air-booking\",\"site\":\"southwest\"}"
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
"site": "southwest"
|
|
54
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "imprint-mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Teach an AI agent how to use any website. Once. Records a real browser session + narration; generates a deterministic MCP tool plus a DOM-replay playbook fallback.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./runtime": "./src/imprint/runtime.ts",
|
|
8
|
+
"./types": "./src/imprint/types.ts"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": "Ashay Changwani",
|
|
12
|
+
"homepage": "https://github.com/ashaychangwani/imprint",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/ashaychangwani/imprint.git"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src/",
|
|
19
|
+
"prompts/",
|
|
20
|
+
"examples/",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE",
|
|
23
|
+
"CHANGELOG.md"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.3.0"
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"imprint": "./src/cli.ts"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"dev": "bun run src/cli.ts",
|
|
33
|
+
"imprint": "bun run src/cli.ts",
|
|
34
|
+
"doctor": "bun run src/cli.ts doctor",
|
|
35
|
+
"test": "bun test",
|
|
36
|
+
"lint": "biome check src test",
|
|
37
|
+
"lint:fix": "biome check --write src test",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"knip": "knip --no-progress",
|
|
40
|
+
"deadcode": "bun run knip && bun run circular",
|
|
41
|
+
"circular": "madge --circular --extensions ts src/",
|
|
42
|
+
"check": "bun run typecheck && bun run lint && bun run test && bun run deadcode",
|
|
43
|
+
"bench:redact": "bun run scripts/redact-benchmark.ts",
|
|
44
|
+
"build:binary": "bun run scripts/build-binary.ts",
|
|
45
|
+
"build:binary:all": "bun run scripts/build-binary.ts --all-targets",
|
|
46
|
+
"changelog": "bunx git-cliff --config cliff.toml --unreleased --strip header",
|
|
47
|
+
"prepare": "git config core.hooksPath .githooks"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@anthropic-ai/sdk": "^0.52.0",
|
|
51
|
+
"@arizeai/phoenix-otel": "^1.0.2",
|
|
52
|
+
"@clack/prompts": "^1.3.0",
|
|
53
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
54
|
+
"@napi-rs/keyring": "^1.3.0",
|
|
55
|
+
"@noble/hashes": "^2.2.0",
|
|
56
|
+
"@opentelemetry/api": "^1.9.1",
|
|
57
|
+
"chrome-remote-interface": "^0.33.0",
|
|
58
|
+
"env-paths": "^3.0.0",
|
|
59
|
+
"libsodium-wrappers": "^0.8.4",
|
|
60
|
+
"node-cron": "^3.0.3",
|
|
61
|
+
"playwright": "^1.49.0",
|
|
62
|
+
"playwright-extra": "^4.3.6",
|
|
63
|
+
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
|
64
|
+
"redactum": "^1.1.0",
|
|
65
|
+
"yaml": "^2.8.4",
|
|
66
|
+
"zod": "^3.24.0"
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"@biomejs/biome": "^1.9.4",
|
|
70
|
+
"@types/bun": "^1.1.0",
|
|
71
|
+
"@types/chrome-remote-interface": "^0.31.14",
|
|
72
|
+
"@types/node": "^22.10.0",
|
|
73
|
+
"@types/node-cron": "^3.0.11",
|
|
74
|
+
"knip": "^5.0.0",
|
|
75
|
+
"madge": "^8.0.0",
|
|
76
|
+
"typescript": "^5.7.0"
|
|
77
|
+
}
|
|
78
|
+
}
|