@commercelayer/agent-cli 0.8.1 → 1.0.0-dev.1
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 +98 -215
- package/dist/argv.js +227 -0
- package/dist/bin.js +15 -178
- package/dist/builtins/auth.js +169 -0
- package/dist/builtins/extensions.js +107 -0
- package/dist/builtins/index.js +8 -0
- package/dist/builtins/loops.js +215 -0
- package/dist/builtins/sandbox.js +186 -0
- package/dist/config.js +238 -0
- package/dist/confirm.js +56 -0
- package/dist/engine.js +205 -0
- package/dist/errors.js +146 -0
- package/dist/help.js +81 -0
- package/dist/http.js +117 -0
- package/dist/inputs.js +112 -0
- package/dist/login.js +136 -0
- package/dist/resources.js +42 -0
- package/dist/schema.js +182 -0
- package/dist/schemas.js +72 -0
- package/dist/secrets.js +204 -0
- package/dist/version.js +4 -8
- package/package.json +4 -9
- package/dist/api/client.js +0 -190
- package/dist/api/server.js +0 -26
- package/dist/api/stub/db.js +0 -37
- package/dist/api/stub/http.js +0 -18
- package/dist/api/stub/index.js +0 -52
- package/dist/api/stub/locale.js +0 -52
- package/dist/api/stub/management.js +0 -409
- package/dist/api/stub/messages.js +0 -126
- package/dist/api/stub/orders.js +0 -625
- package/dist/api/stub/search.js +0 -78
- package/dist/api/stub/shopping.js +0 -673
- package/dist/api/stub/theme.js +0 -197
- package/dist/api/stub/ucp.js +0 -377
- package/dist/api/transport.js +0 -57
- package/dist/api/types.js +0 -4
- package/dist/assets.js +0 -100
- package/dist/catalog.js +0 -372
- package/dist/check.js +0 -187
- package/dist/commands.js +0 -1241
- package/dist/cursor.js +0 -31
- package/dist/files.js +0 -98
- package/dist/inbox/cursor.js +0 -11
- package/dist/inbox/fake.js +0 -95
- package/dist/inbox/imap.js +0 -139
- package/dist/inbox/mailbox.js +0 -25
- package/dist/inbox/message.js +0 -88
- package/dist/inspect.js +0 -237
- package/dist/languages.js +0 -49
- package/dist/links.js +0 -118
- package/dist/locales.js +0 -43
- package/dist/ops.js +0 -190
- package/dist/orderRequest.js +0 -141
- package/dist/output.js +0 -39
- package/dist/products.js +0 -151
- package/dist/promotions.js +0 -291
- package/dist/store.js +0 -379
- package/dist/storefront.js +0 -285
- package/dist/ucp/buy.js +0 -90
- package/dist/ucp/check.js +0 -362
- package/dist/ucp/client.js +0 -129
- package/dist/ucp/images.js +0 -62
- package/dist/ucp/platform.js +0 -90
- package/dist/ucp/shop.js +0 -357
- package/dist/ucp/signing.js +0 -67
- package/dist/web.js +0 -316
- package/dist/website.js +0 -390
package/dist/web.js
DELETED
|
@@ -1,316 +0,0 @@
|
|
|
1
|
-
// Reading a brand's public website, politely: catalog pull (source website) and storefront
|
|
2
|
-
// inspect. robots.txt is honoured for every path, one request at a time and at most 2 a second,
|
|
3
|
-
// 15 s per request and 60 s in all, under a User-Agent that names c11r. Nothing is downloaded to
|
|
4
|
-
// keep: pages are read, and images stay on the brand's servers (their URLs are hot-linked).
|
|
5
|
-
import { fail } from './output.js';
|
|
6
|
-
import { VERSION } from './version.js';
|
|
7
|
-
export const WEB_USER_AGENT = `c11r/${VERSION} (+https://commercelayer.ai)`;
|
|
8
|
-
const ROBOTS_TOKEN = 'c11r';
|
|
9
|
-
/** Pages larger than this are not read: no store page or feed is this big. */
|
|
10
|
-
const MAX_BYTES = 8 * 1024 * 1024;
|
|
11
|
-
const num = (name, fallback) => {
|
|
12
|
-
const v = Number(process.env[name]);
|
|
13
|
-
return Number.isFinite(v) && v >= 0 ? v : fallback;
|
|
14
|
-
};
|
|
15
|
-
export function parseRobots(text) {
|
|
16
|
-
const groups = [];
|
|
17
|
-
const sitemaps = [];
|
|
18
|
-
let current = null;
|
|
19
|
-
let lastWasAgent = false;
|
|
20
|
-
for (const raw of text.split(/\r?\n/)) {
|
|
21
|
-
const line = raw.replace(/#.*$/, '').trim();
|
|
22
|
-
const m = line.match(/^([A-Za-z-]+)\s*:\s*(.*)$/);
|
|
23
|
-
if (!m)
|
|
24
|
-
continue;
|
|
25
|
-
const key = m[1].toLowerCase();
|
|
26
|
-
const value = m[2].trim();
|
|
27
|
-
if (key === 'sitemap') {
|
|
28
|
-
if (value)
|
|
29
|
-
sitemaps.push(value);
|
|
30
|
-
continue;
|
|
31
|
-
}
|
|
32
|
-
if (key === 'user-agent') {
|
|
33
|
-
if (!lastWasAgent || !current)
|
|
34
|
-
groups.push((current = { agents: [], rules: [] }));
|
|
35
|
-
current.agents.push(value.toLowerCase());
|
|
36
|
-
lastWasAgent = true;
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
lastWasAgent = false;
|
|
40
|
-
if (!current || (key !== 'allow' && key !== 'disallow'))
|
|
41
|
-
continue;
|
|
42
|
-
// "Disallow:" with nothing after it allows everything: no rule.
|
|
43
|
-
if (value)
|
|
44
|
-
current.rules.push({ allow: key === 'allow', pattern: value });
|
|
45
|
-
}
|
|
46
|
-
const mine = groups.filter((g) => g.agents.some((a) => a !== '*' && ROBOTS_TOKEN.startsWith(a)));
|
|
47
|
-
const chosen = mine.length ? mine : groups.filter((g) => g.agents.includes('*'));
|
|
48
|
-
return { rules: chosen.flatMap((g) => g.rules), sitemaps };
|
|
49
|
-
}
|
|
50
|
-
const patternRegex = (pattern) => {
|
|
51
|
-
const anchored = pattern.endsWith('$');
|
|
52
|
-
const body = (anchored ? pattern.slice(0, -1) : pattern)
|
|
53
|
-
.split('*')
|
|
54
|
-
.map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
|
|
55
|
-
.join('.*');
|
|
56
|
-
return new RegExp(`^${body}${anchored ? '$' : ''}`);
|
|
57
|
-
};
|
|
58
|
-
/** Whether robots.txt lets c11r read a path (with its query): the longest matching rule wins, allow on a tie. */
|
|
59
|
-
export function robotsAllows(robots, pathAndQuery) {
|
|
60
|
-
if (robots.unavailable)
|
|
61
|
-
return false;
|
|
62
|
-
let best = null;
|
|
63
|
-
for (const r of robots.rules) {
|
|
64
|
-
if (!patternRegex(r.pattern).test(pathAndQuery))
|
|
65
|
-
continue;
|
|
66
|
-
const length = r.pattern.length;
|
|
67
|
-
if (!best || length > best.length || (length === best.length && r.allow))
|
|
68
|
-
best = { allow: r.allow, length };
|
|
69
|
-
}
|
|
70
|
-
return best ? best.allow : true;
|
|
71
|
-
}
|
|
72
|
-
export class BudgetExceeded extends Error {
|
|
73
|
-
}
|
|
74
|
-
/** The one way these commands read the web: robots.txt, pacing, timeouts and the total budget. */
|
|
75
|
-
export class Web {
|
|
76
|
-
robots = new Map();
|
|
77
|
-
last = 0;
|
|
78
|
-
started = Date.now();
|
|
79
|
-
interval = num('C11R_WEB_INTERVAL_MS', 500);
|
|
80
|
-
timeout = num('C11R_WEB_TIMEOUT_MS', 15_000);
|
|
81
|
-
budget = num('C11R_WEB_BUDGET_MS', 60_000);
|
|
82
|
-
requests = 0;
|
|
83
|
-
robotsSkipped = 0;
|
|
84
|
-
budgetExhausted = false;
|
|
85
|
-
remaining() {
|
|
86
|
-
return this.budget - (Date.now() - this.started);
|
|
87
|
-
}
|
|
88
|
-
async raw(url, accept) {
|
|
89
|
-
if (this.remaining() <= 0) {
|
|
90
|
-
this.budgetExhausted = true;
|
|
91
|
-
throw new BudgetExceeded('budget');
|
|
92
|
-
}
|
|
93
|
-
const wait = this.last + this.interval - Date.now();
|
|
94
|
-
if (wait > 0)
|
|
95
|
-
await new Promise((r) => setTimeout(r, wait));
|
|
96
|
-
this.last = Date.now();
|
|
97
|
-
this.requests++;
|
|
98
|
-
const ms = Math.max(1, Math.min(this.timeout, this.remaining()));
|
|
99
|
-
try {
|
|
100
|
-
const res = await fetch(url, { headers: { 'User-Agent': WEB_USER_AGENT, Accept: accept }, redirect: 'follow', signal: AbortSignal.timeout(ms) });
|
|
101
|
-
const length = Number(res.headers.get('content-length') ?? 0);
|
|
102
|
-
const text = length > MAX_BYTES ? '' : await res.text();
|
|
103
|
-
this.last = Date.now();
|
|
104
|
-
return { url: res.url || url, status: res.status, ok: res.ok && length <= MAX_BYTES, type: res.headers.get('content-type') ?? '', text: text.length > MAX_BYTES ? '' : text, headers: res.headers };
|
|
105
|
-
}
|
|
106
|
-
catch (e) {
|
|
107
|
-
this.last = Date.now();
|
|
108
|
-
if (e.name === 'TimeoutError') {
|
|
109
|
-
if (this.remaining() <= 0) {
|
|
110
|
-
this.budgetExhausted = true;
|
|
111
|
-
throw new BudgetExceeded('budget');
|
|
112
|
-
}
|
|
113
|
-
return { url, status: 0, ok: false, type: '', text: '', headers: new Headers() };
|
|
114
|
-
}
|
|
115
|
-
throw e;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
robotsFor(origin) {
|
|
119
|
-
let r = this.robots.get(origin);
|
|
120
|
-
if (!r) {
|
|
121
|
-
r = this.raw(`${origin}/robots.txt`, 'text/plain').then((p) => (p.ok ? parseRobots(p.text) : p.status >= 500 ? { rules: [], sitemaps: [], unavailable: true } : { rules: [], sitemaps: [] }), (e) => {
|
|
122
|
-
if (e instanceof BudgetExceeded)
|
|
123
|
-
throw e;
|
|
124
|
-
return { rules: [], sitemaps: [] };
|
|
125
|
-
});
|
|
126
|
-
this.robots.set(origin, r);
|
|
127
|
-
}
|
|
128
|
-
return r;
|
|
129
|
-
}
|
|
130
|
-
async allowed(url) {
|
|
131
|
-
const u = new URL(url);
|
|
132
|
-
return robotsAllows(await this.robotsFor(u.origin), u.pathname + u.search);
|
|
133
|
-
}
|
|
134
|
-
/**
|
|
135
|
-
* GET a URL robots.txt allows: null when it does not (counted in robotsSkipped), the page
|
|
136
|
-
* otherwise, whatever its status. A redirect to a path robots.txt disallows is null too.
|
|
137
|
-
*/
|
|
138
|
-
async get(url, accept = 'text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8') {
|
|
139
|
-
if (!(await this.allowed(url))) {
|
|
140
|
-
this.robotsSkipped++;
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
const page = await this.raw(url, accept);
|
|
144
|
-
if (page.url !== url && !(await this.allowed(page.url))) {
|
|
145
|
-
this.robotsSkipped++;
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
return page;
|
|
149
|
-
}
|
|
150
|
-
async json(url) {
|
|
151
|
-
const page = await this.get(url, 'application/json');
|
|
152
|
-
if (!page)
|
|
153
|
-
return null;
|
|
154
|
-
let body = null;
|
|
155
|
-
try {
|
|
156
|
-
body = page.ok ? JSON.parse(page.text) : null;
|
|
157
|
-
}
|
|
158
|
-
catch {
|
|
159
|
-
body = null;
|
|
160
|
-
}
|
|
161
|
-
return { page, body };
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
/** The site's origin from what the human gave: a bare host is https. */
|
|
165
|
-
export function siteOrigin(raw, where) {
|
|
166
|
-
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
167
|
-
let u;
|
|
168
|
-
try {
|
|
169
|
-
u = new URL(withScheme);
|
|
170
|
-
}
|
|
171
|
-
catch {
|
|
172
|
-
fail('invalid_argument', `${where} is "${raw}", not a website address.`, 'Use the site\'s address, such as https://brand.example.');
|
|
173
|
-
}
|
|
174
|
-
if (u.protocol !== 'https:' && u.protocol !== 'http:')
|
|
175
|
-
fail('invalid_argument', `${where} is "${raw}", not a website address.`, 'Use an http(s) address, such as https://brand.example.');
|
|
176
|
-
return u.origin;
|
|
177
|
-
}
|
|
178
|
-
/** The first page could not be read at all. */
|
|
179
|
-
export function unreachable(url, e) {
|
|
180
|
-
const cause = e.cause;
|
|
181
|
-
fail('site_unreachable', `Could not reach ${url}: ${cause?.code ?? cause?.message ?? e.message}.`, 'Check the address with the human (open it in a browser), and that this machine is online.');
|
|
182
|
-
}
|
|
183
|
-
const NAMED = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', hellip: '…', ndash: '–', mdash: '—', rsquo: '’', lsquo: '‘', rdquo: '”', ldquo: '“', laquo: '«', raquo: '»', copy: '©', reg: '®', trade: '™', eacute: 'é', egrave: 'è', agrave: 'à', ograve: 'ò', ugrave: 'ù', igrave: 'ì', euro: '€', pound: '£', deg: '°', times: '×' };
|
|
184
|
-
export function decodeEntities(s) {
|
|
185
|
-
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
|
|
186
|
-
if (e[0] === '#') {
|
|
187
|
-
const code = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1));
|
|
188
|
-
return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : m;
|
|
189
|
-
}
|
|
190
|
-
return NAMED[e.toLowerCase()] ?? m;
|
|
191
|
-
});
|
|
192
|
-
}
|
|
193
|
-
/** HTML as plain text, paragraphs kept as blank lines. */
|
|
194
|
-
export function htmlToText(html) {
|
|
195
|
-
return decodeEntities(html
|
|
196
|
-
.replace(/<(script|style|head|template|noscript)\b[^>]*>[\s\S]*?<\/\1>/gi, '')
|
|
197
|
-
.replace(/<br\s*\/?>/gi, '\n')
|
|
198
|
-
.replace(/<li\b[^>]*>/gi, '\n- ')
|
|
199
|
-
.replace(/<\/li>/gi, '')
|
|
200
|
-
.replace(/<\/(p|div|tr|ul|ol|h[1-6]|table|section|blockquote)>/gi, '\n\n')
|
|
201
|
-
.replace(/<[^>]+>/g, ''))
|
|
202
|
-
.replace(/ /g, ' ')
|
|
203
|
-
.replace(/[ \t]+/g, ' ')
|
|
204
|
-
.replace(/ *\n */g, '\n')
|
|
205
|
-
.replace(/\n{3,}/g, '\n\n')
|
|
206
|
-
.trim();
|
|
207
|
-
}
|
|
208
|
-
/** Text on one line, trimmed: names. */
|
|
209
|
-
export const oneLine = (s) => decodeEntities(String(s ?? '').replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim();
|
|
210
|
-
/** At most max characters, cut at a word with an ellipsis. */
|
|
211
|
-
export function clip(s, max) {
|
|
212
|
-
if (s.length <= max)
|
|
213
|
-
return s;
|
|
214
|
-
const cut = s.slice(0, max - 1);
|
|
215
|
-
const at = cut.search(/\s\S*$/);
|
|
216
|
-
return (at > max * 0.6 ? cut.slice(0, at) : cut).trimEnd() + '…';
|
|
217
|
-
}
|
|
218
|
-
const TRACKING = /^(utm_[a-z_]+|fbclid|gclid|gclsrc|dclid|gbraid|wbraid|msclkid|mc_cid|mc_eid|_ga|_gl|igshid|yclid|_hsenc|_hsmi|mkt_tok|ref|ref_src|srsltid)$/i;
|
|
219
|
-
/** An absolute http(s) URL from a link on a page, tracking parameters removed; null for anything else. */
|
|
220
|
-
export function absoluteUrl(href, base) {
|
|
221
|
-
if (typeof href !== 'string' || !href.trim() || /^(data|javascript|blob):/i.test(href.trim()))
|
|
222
|
-
return null;
|
|
223
|
-
let u;
|
|
224
|
-
try {
|
|
225
|
-
u = new URL(decodeEntities(href.trim()), base);
|
|
226
|
-
}
|
|
227
|
-
catch {
|
|
228
|
-
return null;
|
|
229
|
-
}
|
|
230
|
-
if (u.protocol !== 'https:' && u.protocol !== 'http:')
|
|
231
|
-
return null;
|
|
232
|
-
for (const k of [...u.searchParams.keys()])
|
|
233
|
-
if (TRACKING.test(k))
|
|
234
|
-
u.searchParams.delete(k);
|
|
235
|
-
u.hash = '';
|
|
236
|
-
return u.toString();
|
|
237
|
-
}
|
|
238
|
-
/** A sku from a handle or slug: upper case, words joined by underscores. */
|
|
239
|
-
export const skuFromHandle = (handle) => handle
|
|
240
|
-
.normalize('NFKD')
|
|
241
|
-
.replace(/[̀-ͯ]/g, '')
|
|
242
|
-
.toUpperCase()
|
|
243
|
-
.replace(/[^A-Z0-9]+/g, '_')
|
|
244
|
-
.replace(/^_+|_+$/g, '');
|
|
245
|
-
/** The attributes of one tag, names in lower case. */
|
|
246
|
-
export function attributes(tag) {
|
|
247
|
-
const out = {};
|
|
248
|
-
const inner = tag.replace(/^<[\w:-]+/, '').replace(/\/?>$/, '');
|
|
249
|
-
for (const m of inner.matchAll(/([^\s=/>"']+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
|
|
250
|
-
const k = m[1].toLowerCase();
|
|
251
|
-
if (!(k in out))
|
|
252
|
-
out[k] = decodeEntities(m[2] ?? m[3] ?? m[4] ?? '');
|
|
253
|
-
}
|
|
254
|
-
return out;
|
|
255
|
-
}
|
|
256
|
-
/** Every opening tag of a name, with its attributes and where it starts. */
|
|
257
|
-
export function tags(html, name) {
|
|
258
|
-
return [...html.matchAll(new RegExp(`<${name}\\b[^>]*>`, 'gi'))].map((m) => ({ attrs: attributes(m[0]), index: m.index, raw: m[0] }));
|
|
259
|
-
}
|
|
260
|
-
/** The JSON-LD blocks of a page, flattened: arrays and @graph opened, unparsable blocks left out. */
|
|
261
|
-
export function jsonLd(html) {
|
|
262
|
-
const out = [];
|
|
263
|
-
const walk = (v) => {
|
|
264
|
-
if (Array.isArray(v))
|
|
265
|
-
v.forEach(walk);
|
|
266
|
-
else if (v && typeof v === 'object') {
|
|
267
|
-
const o = v;
|
|
268
|
-
out.push(o);
|
|
269
|
-
if (o['@graph'])
|
|
270
|
-
walk(o['@graph']);
|
|
271
|
-
}
|
|
272
|
-
};
|
|
273
|
-
for (const m of html.matchAll(/<script\b[^>]*type\s*=\s*["']?application\/ld\+json["']?[^>]*>([\s\S]*?)<\/script>/gi)) {
|
|
274
|
-
try {
|
|
275
|
-
walk(JSON.parse(m[1].trim().replace(/^<!--|-->$/g, '')));
|
|
276
|
-
}
|
|
277
|
-
catch {
|
|
278
|
-
// A broken block on a page is common: the others still count.
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
return out;
|
|
282
|
-
}
|
|
283
|
-
/** Whether a JSON-LD node is of a type: "@type" is a string or a list. */
|
|
284
|
-
export const isType = (node, type) => {
|
|
285
|
-
const t = node?.['@type'];
|
|
286
|
-
const has = (x) => typeof x === 'string' && (x === type || x.endsWith(`/${type}`) || x.endsWith(`:${type}`));
|
|
287
|
-
return Array.isArray(t) ? t.some(has) : has(t);
|
|
288
|
-
};
|
|
289
|
-
/** <meta name|property=...> content, by name in lower case; the first one wins. */
|
|
290
|
-
export function metas(html) {
|
|
291
|
-
const out = {};
|
|
292
|
-
for (const { attrs } of tags(html, 'meta')) {
|
|
293
|
-
const key = (attrs.property ?? attrs.name ?? attrs.itemprop ?? '').toLowerCase();
|
|
294
|
-
if (key && attrs.content !== undefined && !(key in out))
|
|
295
|
-
out[key] = attrs.content.trim();
|
|
296
|
-
}
|
|
297
|
-
return out;
|
|
298
|
-
}
|
|
299
|
-
/** <link rel=alternate hreflang=...>: language, region, URL. */
|
|
300
|
-
export function hreflangs(html, base) {
|
|
301
|
-
return tags(html, 'link')
|
|
302
|
-
.filter((t) => /\balternate\b/i.test(t.attrs.rel ?? '') && t.attrs.hreflang)
|
|
303
|
-
.map((t) => {
|
|
304
|
-
const h = t.attrs.hreflang.trim();
|
|
305
|
-
const m = h.match(/^([a-z]{2,3})(?:[-_]([a-z]{4}))?(?:[-_]([a-z]{2}|\d{3}))?$/i);
|
|
306
|
-
return { hreflang: h, language: m ? m[1].toLowerCase() : null, region: m?.[3] ? m[3].toUpperCase() : null, url: absoluteUrl(t.attrs.href, base) };
|
|
307
|
-
});
|
|
308
|
-
}
|
|
309
|
-
/** The platform a page runs on, from what Shopify and WooCommerce always leave in it. */
|
|
310
|
-
export function platformOf(html, headers) {
|
|
311
|
-
if (headers?.get('x-shopid') || headers?.get('x-shopify-stage') || /cdn\.shopify\.com|Shopify\.shop\s*=|shopify-section|window\.Shopify\b/.test(html))
|
|
312
|
-
return 'shopify';
|
|
313
|
-
if (/wp-content\/plugins\/woocommerce|\bwoocommerce\b|wc-block-|wc\/store\/v1/i.test(html))
|
|
314
|
-
return 'woocommerce';
|
|
315
|
-
return 'unknown';
|
|
316
|
-
}
|