@commercelayer/agent-cli 0.7.0 → 0.8.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/README.md CHANGED
@@ -18,7 +18,8 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
18
18
  | `init [--name <business name>]` | Create a sandbox store, with no login. The name (the folder name when absent) names the organization; the org slug and store hostname derive from it, numbered when taken. Writes `store.yml` (a commented template) and `credentials`, plus a short `AGENTS.md` and `CLAUDE.md` section (appended to an existing file, never twice) that sends later agents in the repo to [commercelayer.ai/skills.md](https://commercelayer.ai/skills.md). Reuses the store if they exist. |
19
19
  | `plan` | Validate `store.yml` and the product files, and list what `apply` would create, update or delete: markets, products, images, and the store's name, policies and theme. `missing_images` lists `img/<file>` references with no file in `.commercelayer/img/` (`apply` refuses them); `unused_images` lists files there that nothing references. |
20
20
  | `apply [--allow-delete] [--full]` | Make the store match the files, the website included: the theme settings, and the images in `.commercelayer/img/` that the theme and products reference, uploaded when new or changed (unused ones are dropped from the store host). Refuses deletes without `--allow-delete`. Product changes go through a Commerce Layer import: `apply` waits for it, with progress as JSON lines on stderr, and prints one result. `apply_failed` lists the products Commerce Layer refused. While Commerce Layer is busy or unavailable, `apply` keeps reading (backing off on busy) and writes a `retrying` line. `apply_pending` means the import keeps going after 10 minutes, and `c11r status` follows it. An apply still running is finished before a new one starts. `--full` re-imports every product and price, after edits in the Commerce Layer dashboard. |
21
- | `catalog pull` | Read the products from the catalog source in `store.yml` (a Supabase table, a Google Sheet or a Google Merchant Center feed) into `.commercelayer/products/catalog.csv`. Then `plan` and `apply` as usual. |
21
+ | `catalog pull` | Read the products from the catalog source in `store.yml` (a Supabase table, a Google Sheet, a Google Merchant Center feed, or a brand's website for a demo store) into `.commercelayer/products/catalog.csv`. Then `plan` and `apply` as usual. `--currency <code>` gives a website's currency when the site does not say it. |
22
+ | `storefront inspect <url>` | Read a brand's website, read-only: title, languages, theme colour, icons, `og:image`, logo candidates, the colours its CSS uses most, the currencies and countries it sells in, the platform (Shopify, WooCommerce or unknown) and whether `robots.txt` lets `catalog pull` read its products. For a demo store that looks like the site: the agent picks `theme.color` and `theme.logo` (a URL, hot-linked) from it. Writes no file. |
22
23
  | `storefront init [--force]` | Custom storefronts only. Write a static storefront on [drop-in.js](https://commercelayer.github.io/drop-in.js/), no build step, wired to the sandbox's sales channel. `index.html`: the config block, the products grouped by top-level category with a navigation, a minicart that slides in on add to cart, the hosted cart and checkout, and a product page at `#/p/<sku>` built from `<template id="product-template">` and the `#products` data. `styles.css`: theme tokens first (`--paper`, `--ink`, `--muted`, `--line`, `--accent`, `--on-accent`, `--radius`, `--font`), which also restyle drop-in.js's prices, buttons and links, so a dark theme is a token change; then the drop-in.js overrides, then the page. `app.js`: the market switch, the category links and the product page. Product images are the store's, at `/img/<file>` (from `.commercelayer/img/`, uploaded by `apply`); products with no image show the tile the store host draws for them, at `/tiles/<sku>.svg`. The site's own files go in `storefront/assets/`. Refuses to overwrite without the flag; with it, rewrites the three files. |
23
24
  | `storefront publish` | Custom storefronts only. Upload the folder at `storefront.path` (default `storefront`) to the store host, where it replaces the hosted theme, leaving out dotfiles. `/img/` on the store host is always the store's images, so files under the folder's `img/` are not served: publish lists them as `not_served`. |
24
25
  | `storefront check` | Custom storefronts only. Read the published storefront at the preview URL and check it: drop-in.js and its config block wired to this store, the drop-in elements, an add to cart for every product and none for unknown skus, every image loading, alt text, stylesheets after drop-in.css, the theme tokens and drop-in overrides (no dead `::part` rules), title, `lang`, viewport, description, and whether the folder changed since publish. Prints `{ status, summary, checks: [{ id, kind, status: pass\|warn\|fail, message, fix, items }], render }` and exits 1 when a check fails; warnings do not fail. `kind` is `static` today; a later rendering phase adds `render` checks and fills `render`. |
@@ -146,14 +147,18 @@ A store has at most 6 languages, the base one included. Every country gets its o
146
147
  When the products live in a tool the human edits too, `store.yml` names it and `c11r catalog pull` copies it into `.commercelayer/products/catalog.csv`, sorted by sku: same source, same file. Pull applies the product rules above before it writes, with errors naming the row in the source, and reports the skus added, changed and removed. Removed products leave the store only through `apply --allow-delete`. Every source has the same columns: `sku`, `name`, `description`, `image_url`, `category`, and one price column per market currency, named by its ISO 4217 code in lowercase (`eur`, `usd`).
147
148
  ```yaml
148
149
  catalog:
149
- source: sheets # sheets | supabase | merchant
150
+ source: sheets # sheets | supabase | merchant | website
150
151
  url: https://docs.google.com/spreadsheets/d/e/2PACX-.../pub?output=csv
151
152
  # table: products # supabase only
153
+ # limit: 100 # website only: how many products (at most 500)
152
154
  ```
153
155
  - `sheets`: a Google Sheet, by its CSV link (File > Share > Publish to web > Comma-separated values) or by its own link when anyone with the link can view it. No key. Translations are `name_<ll>`, `description_<ll>` and `category_<ll>` columns, in sheets and Supabase tables alike.
154
156
  - `supabase`: a table (default `products`), read over the REST API with the project's anon key: `C11R_SUPABASE_URL=https://<project>.supabase.co` and `C11R_SUPABASE_KEY=<anon key>` in `.commercelayer/credentials`, or the environment. The key needs a row level security policy that allows select. Never use the `service_role` key. Columns `id`, `created_at` and `updated_at` are skipped.
155
157
  - `merchant`: a Google Merchant Center product feed URL, RSS 2.0 (XML, `g:` namespace) or TSV. `id` is the sku, `title` the name, `description`, `image_link` the image, `price` (`14.50 EUR`) the price in its currency, `product_type` the category. An id listed once per currency is one product with several prices. Other attributes, `sale_price` included, are ignored. Other languages: `catalog.translations: { it: <the Italian feed URL> }`, one feed per language, merged by `id` (`title`, `description`, `product_type`).
156
- Error codes: `no_catalog_source`, `invalid_catalog`, `missing_credentials`, `catalog_unreachable`, `catalog_unauthorized`, `catalog_not_found`, `catalog_not_csv`, `catalog_error`, `empty_catalog`, plus `invalid_product` and `missing_price`.
158
+ - `website`: a brand's own website, for a demo store (the human's site, or a brand they are pitching to). `url` is the site, `limit` how many products (default 100, at most 500). Pull tries, in order, and the first that yields products wins: Shopify's public `products.json` (also `/collections/all/products.json`), WooCommerce's Store API (`/wp-json/wc/store/v1/products`), then the product pages its sitemap lists (`/products/`, `/product/`, `/p/`, `/shop/`, `/item/`) and their schema.org Product JSON-LD. One row per product: the first available variant's sku and price (or the handle in upper snake case), the title, the description as plain text (paragraphs kept, at most 2,000 characters), the first image, the product type or breadcrumb as category. Products without a price are left out, and a sku seen twice keeps the first. Prices are the site's, in the site's currency, never converted: the store's markets must sell in it (`currency_mismatch` otherwise), and when the site does not say it, `currency_unknown` asks for `--currency <code>`. Translations are not pulled yet; `source_site.languages` lists the site's languages. **Images and the logo stay on the brand's servers: `image_url` links them, nothing is copied.** Pull reads politely: `robots.txt` is honoured for every path (`robots_skipped` counts what it disallows), one request at a time and at most 2 a second, 15 s per request and 60 s in all (`budget_exhausted` when it stopped early, with what it read), as `c11r/<version> (+https://commercelayer.ai)`. It prints `strategy`, `source_site: { url, title, platform, product_count_seen, currency, languages }`, `pulled`, `variants_skipped`, `robots_skipped`, `no_price_skipped`, `duplicates_skipped`, `other_currency_skipped`, `tried: [{ strategy, result }]` and `requests`, so the agent can tell the human "I found 340 products, pulled 100".
159
+ Error codes: `no_catalog_source`, `invalid_catalog`, `missing_credentials`, `catalog_unreachable`, `catalog_unauthorized`, `catalog_not_found`, `catalog_not_csv`, `catalog_error`, `empty_catalog`, plus `invalid_product` and `missing_price`; for a website `site_unreachable`, `no_products_found` (ask the human for a feed or a sheet instead), `currency_unknown` and `currency_mismatch`.
160
+
161
+ `storefront inspect <url>` reads the same way, one page and up to 3 of its stylesheets, and writes nothing: `{ url, title, site_name, description, language, languages, theme_color, icons, og_image, logo_candidates: [{ url, source: json_ld|header_img|img|header_svg, alt, width, height }], colors: [{ hex, source: theme_color|css, count, neutral }], currencies_seen, countries_seen, platform, robots: { products_allowed, paths }, next }`. An inline SVG logo has `url: null`: there is nothing to link. Errors: `missing_argument`, `site_unreachable`, `site_error`, `robots_disallowed`.
157
162
 
158
163
  ### The website
159
164
  The theme renders the home page (logo, hero with name and tagline, products by top-level category), a page per category at `/categories/<slug>/...` and per product at `/products/<sku>`, with the market from `?market=<code>`, cart and checkout. Images written `img/<file>` (`theme.logo`, `theme.hero`, a product's `image_url`) are files in `.commercelayer/img/`, served at `<preview_url>/img/<file>`: paths of letters, digits, `.`, `_`, `-` and `/`, a lowercase `png`, `jpg`, `jpeg`, `webp`, `gif`, `avif` or `svg` extension, at most 5 MB each. `apply` uploads the new and changed ones (by SHA-256) before anything else, and the store host drops the ones nothing references any more.
package/dist/bin.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import * as cmd from './commands.js';
6
6
  import { CliError, print } from './output.js';
7
7
  import { LATEST_HINT, NPX } from './version.js';
8
- const VALUE_OPTIONS = new Set(['--name', '--sku', '--quantity', '--country', '--code', '--file', '--store', '--item', '--email', '--first-name', '--last-name', '--phone', '--street', '--city', '--postal-code', '--region', '--shipping', '--category', '--min-price', '--max-price', '--status', '--payment', '--fulfillment', '--limit', '--archived', '--amount', '--carrier', '--tracking', '--tracking-url', '--since', '--until', '--by', '--company', '--line-2', '--query', '--reference', '--notes', '--language', '--source', '--channel', '--label', '--unlabel', '--move']);
8
+ const VALUE_OPTIONS = new Set(['--name', '--sku', '--quantity', '--country', '--code', '--file', '--store', '--item', '--email', '--first-name', '--last-name', '--phone', '--street', '--city', '--postal-code', '--region', '--shipping', '--category', '--min-price', '--max-price', '--status', '--payment', '--fulfillment', '--limit', '--archived', '--amount', '--carrier', '--tracking', '--tracking-url', '--since', '--until', '--by', '--company', '--line-2', '--query', '--reference', '--notes', '--language', '--source', '--channel', '--label', '--unlabel', '--move', '--currency']);
9
9
  const argv = process.argv.slice(2);
10
10
  const flags = new Set();
11
11
  const values = {};
@@ -54,7 +54,7 @@ async function run() {
54
54
  return cmd.claim();
55
55
  case 'catalog':
56
56
  if (sub === 'pull')
57
- return cmd.catalogPull();
57
+ return cmd.catalogPull(values);
58
58
  throw new CliError('unknown_command', `There is no command "catalog ${sub ?? ''}".`, 'Use `c11r catalog pull`.');
59
59
  case 'storefront':
60
60
  if (sub === 'init')
@@ -63,7 +63,9 @@ async function run() {
63
63
  return cmd.storefrontPublish();
64
64
  if (sub === 'check')
65
65
  return cmd.storefrontCheckCmd();
66
- throw new CliError('unknown_command', `There is no command "storefront ${sub ?? ''}".`, 'Use `c11r storefront init`, `storefront publish` or `storefront check`.');
66
+ if (sub === 'inspect')
67
+ return cmd.storefrontInspect(args[2]);
68
+ throw new CliError('unknown_command', `There is no command "storefront ${sub ?? ''}".`, 'Use `c11r storefront init`, `storefront publish`, `storefront check` or `storefront inspect <url>`.');
67
69
  case 'promotions':
68
70
  if (sub === 'list')
69
71
  return cmd.promotionsList();
package/dist/catalog.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // c11r catalog pull: the products live in a tool the human edits too (a Supabase
2
- // table, a Google Sheet, a Google Merchant Center feed). Pull reads them and
3
- // writes .commercelayer/products/catalog.csv, so plan and apply work as always.
2
+ // table, a Google Sheet, a Google Merchant Center feed), or on a brand's website (a demo
3
+ // store). Pull reads them and writes .commercelayer/products/catalog.csv, so plan and apply
4
+ // work as always.
4
5
  // Every source maps onto the same fixed schema: sku, name, description,
5
6
  // image_url, category and one price column per currency.
6
7
  import fs from 'node:fs';
@@ -11,28 +12,32 @@ import { CliError, fail } from './output.js';
11
12
  import { parseCsv, readProductFile, toCsv } from './products.js';
12
13
  import { ISO_639_1, setPlain, setTranslated, translatedColumn, TRANSLATED } from './languages.js';
13
14
  import { canonical, checkProducts, readStoreFile, storeLanguage } from './store.js';
14
- export const SOURCES = ['supabase', 'sheets', 'merchant'];
15
+ import { siteOrigin } from './web.js';
16
+ import { fromWebsite, WEBSITE_LIMIT, WEBSITE_MAX } from './website.js';
17
+ export const SOURCES = ['supabase', 'sheets', 'merchant', 'website'];
15
18
  export const catalogFile = path.join(paths.products, 'catalog.csv');
16
19
  const FIELDS = ['sku', 'name', 'description', 'image_url', 'category'];
17
20
  /** Columns tools add on their own (Supabase's defaults): pull skips them. */
18
21
  const IGNORED = ['id', 'created_at', 'updated_at'];
19
22
  /** Supabase rows per request; pull pages until a page comes back empty. */
20
23
  const PAGE = 1000;
21
- const EXAMPLE = 'For example `catalog: { source: sheets, url: <the sheet\'s CSV link> }`. Sources are supabase, sheets and merchant; see the comments in store.yml.';
24
+ const EXAMPLE = 'For example `catalog: { source: sheets, url: <the sheet\'s CSV link> }`. Sources are supabase, sheets, merchant and website; see the comments in store.yml.';
22
25
  function readConfig(doc) {
23
26
  const c = doc.catalog;
24
27
  if (!c || typeof c !== 'object')
25
28
  fail('no_catalog_source', 'store.yml has no catalog source.', `Add a \`catalog:\` entry to ${paths.store}. ${EXAMPLE}`);
26
29
  if (!SOURCES.includes(c.source))
27
- fail('invalid_catalog', `catalog.source in store.yml is "${c.source ?? ''}".`, `Use supabase, sheets or merchant. ${EXAMPLE}`);
30
+ fail('invalid_catalog', `catalog.source in store.yml is "${c.source ?? ''}".`, `Use supabase, sheets, merchant or website. ${EXAMPLE}`);
28
31
  const table = String(c.table ?? 'products');
29
32
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table))
30
33
  fail('invalid_catalog', `catalog.table in store.yml is "${table}".`, 'Use the name of the Supabase table, such as products.');
31
34
  const url = String(c.url ?? '');
32
35
  if (c.source !== 'supabase' && !/^https?:\/\//.test(url)) {
33
- fail('invalid_catalog', url ? `catalog.url in store.yml is "${url}", not a URL.` : 'catalog.url is missing in store.yml.', c.source === 'sheets'
34
- ? 'Set url to the Google Sheet\'s CSV link (File > Share > Publish to web > Comma-separated values), or to the sheet\'s own link if it is shared with anyone who has the link.'
35
- : 'Set url to the Merchant Center product feed (RSS/XML or TSV), as Merchant Center fetches it.');
36
+ fail('invalid_catalog', url ? `catalog.url in store.yml is "${url}", not a URL.` : 'catalog.url is missing in store.yml.', c.source === 'website'
37
+ ? "Set url to the brand's website, such as https://brand.example."
38
+ : c.source === 'sheets'
39
+ ? 'Set url to the Google Sheet\'s CSV link (File > Share > Publish to web > Comma-separated values), or to the sheet\'s own link if it is shared with anyone who has the link.'
40
+ : 'Set url to the Merchant Center product feed (RSS/XML or TSV), as Merchant Center fetches it.');
36
41
  }
37
42
  const translations = {};
38
43
  if (c.translations !== undefined && c.translations !== null) {
@@ -48,7 +53,15 @@ function readConfig(doc) {
48
53
  translations[ll] = String(u);
49
54
  }
50
55
  }
51
- return { source: c.source, url, table, translations };
56
+ let limit = WEBSITE_LIMIT;
57
+ if (c.limit !== undefined && c.limit !== null) {
58
+ if (c.source !== 'website')
59
+ fail('invalid_catalog', `catalog.limit is for a website; this source is ${c.source}.`, 'Pull reads every product of a sheet, a table or a feed: remove limit.');
60
+ limit = Number(c.limit);
61
+ if (!Number.isInteger(limit) || limit < 1 || limit > WEBSITE_MAX)
62
+ fail('invalid_catalog', `catalog.limit in store.yml is "${c.limit}".`, `Use a whole number from 1 to ${WEBSITE_MAX} (default ${WEBSITE_LIMIT}).`);
63
+ }
64
+ return { source: c.source, url: c.source === 'website' ? siteOrigin(url, 'catalog.url in store.yml') : url, table, translations, limit };
52
65
  }
53
66
  async function get(url, what, headers = {}) {
54
67
  try {
@@ -271,7 +284,7 @@ async function fromMerchant(c) {
271
284
  }
272
285
  return out;
273
286
  }
274
- export async function pull() {
287
+ export async function pull(values = {}) {
275
288
  if (!fs.existsSync(paths.store))
276
289
  fail('no_store', 'There is no store in this repo.', 'Run `c11r init` in the repo root first.');
277
290
  let doc;
@@ -285,16 +298,27 @@ export async function pull() {
285
298
  const config = readConfig(doc);
286
299
  const markets = Array.isArray(doc.markets) ? doc.markets : [];
287
300
  const currencies = [...new Set(markets.map((m) => m?.currency).filter(Boolean).map(String))];
288
- const sourced = config.source === 'supabase' ? await fromSupabase(config, currencies) : config.source === 'sheets' ? await fromSheet(config, currencies) : await fromMerchant(config);
301
+ if (values.currency !== undefined && config.source !== 'website')
302
+ fail('invalid_argument', `--currency is for a website catalog; this source is ${config.source}.`, 'A sheet, a table or a feed says its currencies in its columns: leave out --currency.');
303
+ let site = null;
304
+ let sourced;
305
+ if (config.source === 'website') {
306
+ const r = await fromWebsite(config, currencies, values.currency);
307
+ site = r.report;
308
+ sourced = r.products.map((product) => ({ product, source: `${site.source_site.url}, product ${product.sku}` }));
309
+ }
310
+ else
311
+ sourced = config.source === 'supabase' ? await fromSupabase(config, currencies) : config.source === 'sheets' ? await fromSheet(config, currencies) : await fromMerchant(config);
312
+ const file = catalogFile;
289
313
  if (sourced.length === 0) {
290
314
  const rls = config.source === 'supabase' ? ' If the table has rows, row level security hides them from the anon key: ask the human to add a policy that allows select.' : '';
291
315
  fail('empty_catalog', `The ${config.source} catalog has no products.`, `Ask the human to add products to it, then pull again.${rls}`);
292
316
  }
293
317
  const products = checkProducts(sourced, currencies, storeLanguage(doc.language));
294
318
  let before = new Map();
295
- if (fs.existsSync(catalogFile)) {
319
+ if (fs.existsSync(file)) {
296
320
  try {
297
- before = new Map(readProductFile(catalogFile).map((s) => [String(s.product.sku), s.product]));
321
+ before = new Map(readProductFile(file).map((s) => [String(s.product.sku), s.product]));
298
322
  }
299
323
  catch (e) {
300
324
  if (!(e instanceof CliError))
@@ -303,22 +327,40 @@ export async function pull() {
303
327
  }
304
328
  const after = new Map(products.map((p) => [p.sku, p]));
305
329
  const csv = toCsv(products);
306
- const changed = !fs.existsSync(catalogFile) || fs.readFileSync(catalogFile, 'utf8') !== csv;
330
+ const changed = !fs.existsSync(file) || fs.readFileSync(file, 'utf8') !== csv;
307
331
  if (changed) {
308
332
  fs.mkdirSync(paths.products, { recursive: true });
309
- fs.writeFileSync(catalogFile, csv);
333
+ fs.writeFileSync(file, csv);
310
334
  }
311
335
  const removed = [...before.keys()].filter((sku) => !after.has(sku));
312
336
  return {
313
337
  status: changed ? 'pulled' : 'up_to_date',
314
338
  source: config.source,
315
- file: catalogFile,
339
+ ...(site ? { strategy: site.strategy } : {}),
340
+ file,
316
341
  products: products.length,
317
342
  added: [...after.keys()].filter((sku) => !before.has(sku)),
318
343
  changed: [...after.keys()].filter((sku) => before.has(sku) && canonical(before.get(sku)) !== canonical(after.get(sku))),
319
344
  removed,
320
- next: changed
321
- ? `Run c11r plan to see what apply will change in the store.${removed.length ? ' Removed products are deleted from the store only by c11r apply --allow-delete, once the human agrees.' : ''} ${catalogFile} is rewritten by every pull: change products in the ${config.source} source, not in the file.`
322
- : `${catalogFile} already matches the ${config.source} source.`,
345
+ ...(site ? websiteOutput(site, products.length) : {}),
346
+ next: site
347
+ ? websiteNext(site, products.length, changed, removed.length, file)
348
+ : changed
349
+ ? `Run c11r plan to see what apply will change in the store.${removed.length ? ' Removed products are deleted from the store only by c11r apply --allow-delete, once the human agrees.' : ''} ${file} is rewritten by every pull: change products in the ${config.source} source, not in the file.`
350
+ : `${file} already matches the ${config.source} source.`,
323
351
  };
324
352
  }
353
+ function websiteOutput(r, pulled) {
354
+ const { strategy: _, ...rest } = r;
355
+ return { ...rest, pulled };
356
+ }
357
+ function websiteNext(r, pulled, changed, removed, file) {
358
+ const seen = r.source_site.product_count_seen;
359
+ const found = seen !== null && seen > pulled ? `I found ${seen} products on the site and pulled ${pulled} (catalog.limit is ${r.limit})` : `I pulled ${pulled} products from the site`;
360
+ const left = [r.no_price_skipped && `${r.no_price_skipped} without a price`, r.duplicates_skipped && `${r.duplicates_skipped} with a sku already taken`, r.robots_skipped && `${r.robots_skipped} pages robots.txt disallows`, r.other_currency_skipped && `${r.other_currency_skipped} in another currency`].filter(Boolean);
361
+ return [
362
+ `Tell the human: "${found}, in ${r.source_site.currency}${r.variants_skipped ? `, one variant each (${r.variants_skipped} other variants left out)` : ''}." ${left.length ? `Left out: ${left.join(', ')}. ` : ''}${r.budget_exhausted ? 'The site was slow: pull stopped at 60 s, so this is what it read by then. ' : ''}`,
363
+ 'Images and the logo stay on the brand\'s servers: image_url links them, nothing is copied. ',
364
+ changed ? `Run c11r plan to see what apply will change in the store.${removed ? ' Removed products are deleted from the store only by c11r apply --allow-delete, once the human agrees.' : ''} ${file} is rewritten by every pull: never edit it; products to add go in another file in ${paths.products}.` : `${file} already matches the site.`,
365
+ ].join('');
366
+ }
package/dist/commands.js CHANGED
@@ -9,6 +9,7 @@ import { diff, hashStore, languageWarnings, loadStore, storefrontMode, storefron
9
9
  import { storefrontFiles } from './storefront.js';
10
10
  import { imageNotes, planAssets, uploadAssets } from './assets.js';
11
11
  import { pull } from './catalog.js';
12
+ import { inspect } from './inspect.js';
12
13
  import * as ops from './ops.js';
13
14
  import { checkOrderRequest, lineFlag, sourceFlag } from './orderRequest.js';
14
15
  import { readInboxCursor, seenKey, writeInboxCursor } from './inbox/cursor.js';
@@ -30,9 +31,10 @@ export const HELP = {
30
31
  init: 'Create a sandbox store for this repo, with no login: c11r init --name "<business name>" (the folder name when --name is absent). The name becomes the organization\'s name, and the org slug and store hostname derive from it. Writes .commercelayer/store.yml and .commercelayer/credentials, and a section in AGENTS.md and CLAUDE.md (created, or appended once) that points later agents in this repo to the skills: commit them. Reuses the store if store.yml already exists.',
31
32
  plan: 'Show what apply would create, update or delete: markets, products, images, and the store\'s name, language, policies and theme; missing_images lists references with no file in .commercelayer/img/ (apply refuses them), unused_images files nothing references, from .commercelayer/store.yml and the product files under .commercelayer/products/ (*.yml lists, *.csv rows, merged by sku). Products may carry other languages: name, description and category as { <ll>: text } maps (the base language, store.yml language:, default en, among them), or name_<ll>, description_<ll>, category_<ll> CSV columns; at most 6 languages. warnings lists a language no country of the store speaks.',
32
33
  apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/: markets, products, the storefront mode (storefront.mode: hosted, the default, or agent-only: UCP and the pages it needs, no catalog pages), the theme (its settings, badge included, and the images in .commercelayer/img/ that the theme and products reference, uploaded when new or changed), name and policies. The store\'s website is live at preview_url after apply. Refuses deletes unless --allow-delete is given. Product changes go through a Commerce Layer import: apply waits for it (progress as JSON lines on stderr) and prints one result; if it stops with apply_pending the import keeps going, so run `c11r status` to follow it. While Commerce Layer is busy or unavailable it keeps reading, with a retrying line. An apply still running is finished first. --full re-imports every product and price, after edits in the Commerce Layer dashboard.',
33
- 'catalog pull': 'Read the products from the catalog source in store.yml (catalog: { source: supabase | sheets | merchant, url, table }) and write them to .commercelayer/products/catalog.csv; then plan and apply as usual. Columns: sku, name, description, image_url, category, name_<ll>, description_<ll> and category_<ll> for other languages (name_it), and one price column per market currency, in lowercase (eur, usd). A Merchant feed adds other languages with catalog.translations: { it: <feed url> }, merged by id. Supabase reads C11R_SUPABASE_URL and C11R_SUPABASE_KEY (the anon key) from .commercelayer/credentials or the environment; a Google Sheet needs its CSV link or a link anyone can open; a Merchant Center feed is RSS/XML or TSV.',
34
+ 'catalog pull': 'Read the products from the catalog source in store.yml (catalog: { source: supabase | sheets | merchant | website, url, table, limit }) and write them to .commercelayer/products/catalog.csv; then plan and apply as usual. Columns: sku, name, description, image_url, category, name_<ll>, description_<ll> and category_<ll> for other languages (name_it), and one price column per market currency, in lowercase (eur, usd). A Merchant feed adds other languages with catalog.translations: { it: <feed url> }, merged by id. Supabase reads C11R_SUPABASE_URL and C11R_SUPABASE_KEY (the anon key) from .commercelayer/credentials or the environment; a Google Sheet needs its CSV link or a link anyone can open; a Merchant Center feed is RSS/XML or TSV. source website reads a brand\'s site for a demo store (url: the site, limit: default 100, at most 500): Shopify products.json, then the WooCommerce Store API, then the sitemap\'s product pages and their JSON-LD, first that yields products wins (strategy). One row per product, first available variant (variants_skipped counts the others); prices in the site\'s currency, never converted: currency_mismatch when the markets sell in another, currency_unknown when the site does not say (ask the human, then --currency <code>). It honours robots.txt (robots_skipped), reads one request at a time, at most 2 a second, 60 s in all (budget_exhausted). Images stay on the brand\'s servers: image_url links them, nothing is copied. Prints strategy, source_site { url, title, platform, product_count_seen, currency, languages }, pulled and the skipped counts: tell the human how many products the site has and how many were pulled. no_products_found: ask the human for a feed or a sheet instead.',
34
35
  'storefront init': 'Custom storefronts only: the hosted theme needs none of this (set theme: in store.yml and apply). Write a static storefront on drop-in.js, no build step: storefront/index.html (the products by category, a minicart that opens on add to cart, the hosted cart and checkout, and a product page at #/p/<sku>), styles.css (theme tokens first) and app.js. Product images are the store\'s, at /img/ (.commercelayer/img/, uploaded by apply); the site\'s own files go in storefront/assets/. Refuses to overwrite unless --force is given.',
35
36
  'storefront check': 'Custom storefronts only. Check the published storefront at the preview URL: drop-in.js and its config block wired to this store, the drop-in elements, an add to cart for every product, every image loading, alt text, stylesheets after drop-in.css, the theme tokens and drop-in overrides, title, lang, viewport, description, and whether the folder changed since publish. Prints { status, summary, checks: [{ id, kind, status: pass|warn|fail, message, fix, items }] }; exits 1 when a check fails.',
37
+ 'storefront inspect': "Read a brand's website for a demo store that looks like it, read-only, nothing written: c11r storefront inspect <url>. Returns { url, title, site_name, description, language, languages, theme_color, icons, og_image, logo_candidates: [{ url, source: json_ld|header_img|img|header_svg, alt, width, height }], colors: [{ hex, source: theme_color|css, count, neutral }], currencies_seen, countries_seen, platform: shopify|woocommerce|unknown, robots: { products_allowed, paths } }. Pick theme.color (the first colour that is not neutral, usually) and theme.logo (a logo candidate's url: hot-linked, never downloaded; an inline SVG has url null) and show the human. robots.products_allowed false: catalog pull cannot read the products, ask for a feed or a sheet. Same polite reading as catalog pull (robots.txt, 2 requests a second). Errors: site_unreachable, site_error, robots_disallowed.",
36
38
  'storefront publish': 'Custom storefronts only: upload the storefront folder (store.yml storefront.path, default storefront) to the preview URL, where it replaces the hosted theme.',
37
39
  'promotions list': 'The store\'s promotions, live in Commerce Layer (not in store files): id, name, status (active, scheduled, expired, disabled, used_up), rules, actions, uses, starts, ends; managed: false marks one made in the dashboard (its id is Commerce Layer\'s), which can be enabled, disabled or deleted but not updated. warnings flags more than 10 active promotion actions without a coupon (Commerce Layer applies 10).',
38
40
  'promotions show': 'One promotion by id, as list shows it: c11r promotions show <id>.',
@@ -242,8 +244,11 @@ export async function apply(flags) {
242
244
  apply: job && done.apply ? jobSummary(done.apply) : undefined,
243
245
  };
244
246
  }
245
- export async function catalogPull() {
246
- return pull();
247
+ export async function catalogPull(values) {
248
+ return pull(values);
249
+ }
250
+ export async function storefrontInspect(url) {
251
+ return inspect(url);
247
252
  }
248
253
  export async function storefrontInit(flags) {
249
254
  const api = createApi(requireCredentials());
@@ -0,0 +1,230 @@
1
+ // c11r storefront inspect <url>: what a brand's website says about itself, for a demo store that
2
+ // looks like it: title, languages, the logo, the colours, the currencies and countries it sells
3
+ // in, the platform, and whether robots.txt lets catalog pull read its products. Read-only: it
4
+ // writes no file and downloads nothing to keep; the logo is used by its URL (hot-linked).
5
+ import { fail } from './output.js';
6
+ import { absoluteUrl, BudgetExceeded, hreflangs, isType, jsonLd, metas, oneLine, platformOf, robotsAllows, siteOrigin, tags, unreachable, Web } from './web.js';
7
+ const MAX_STYLESHEETS = 3;
8
+ const MAX_LOGOS = 5;
9
+ const MAX_COLORS = 5;
10
+ const hex6 = (h) => {
11
+ const x = h.replace('#', '').toLowerCase();
12
+ return `#${x.length === 3 ? [...x].map((c) => c + c).join('') : x}`;
13
+ };
14
+ const rgbHex = (r, g, b) => `#${[r, g, b].map((n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0')).join('')}`;
15
+ /** Greys, white and black: a page's text and background, rarely its brand colour. */
16
+ const neutral = (hex) => {
17
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
18
+ return Math.max(r, g, b) - Math.min(r, g, b) < 16;
19
+ };
20
+ /** Every colour written in CSS: #rgb, #rrggbb and rgb()/rgba(), counted. */
21
+ export function cssColors(css, into = new Map()) {
22
+ const add = (h) => into.set(h, (into.get(h) ?? 0) + 1);
23
+ for (const m of css.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})(?![0-9a-z_-])/gi))
24
+ add(hex6(m[1]));
25
+ for (const m of css.matchAll(/rgba?\(\s*(\d{1,3})[\s,]+(\d{1,3})[\s,]+(\d{1,3})(?:\s*[,/]\s*([\d.]+%?))?\s*\)/gi)) {
26
+ // A transparent colour is an overlay, not a colour of the brand.
27
+ if (m[4] !== undefined && parseFloat(m[4]) === 0)
28
+ continue;
29
+ add(rgbHex(Number(m[1]), Number(m[2]), Number(m[3])));
30
+ }
31
+ return into;
32
+ }
33
+ const num = (v) => {
34
+ const n = Number(String(v ?? '').replace(/px$/, ''));
35
+ return Number.isFinite(n) && n > 0 ? n : undefined;
36
+ };
37
+ /** Where the header is: the <header> element, else the top of the body. */
38
+ const headerOf = (html) => {
39
+ const h = html.match(/<header\b[\s\S]*?<\/header>/i)?.[0];
40
+ if (h)
41
+ return h;
42
+ const body = html.search(/<body\b/i);
43
+ return html.slice(Math.max(0, body), Math.max(0, body) + 20_000);
44
+ };
45
+ export function logoCandidates(html, base, siteTitle) {
46
+ const out = [];
47
+ for (const node of jsonLd(html)) {
48
+ if (!['Organization', 'Brand', 'OnlineStore', 'Store', 'Corporation', 'LocalBusiness', 'WebSite'].some((t) => isType(node, t)))
49
+ continue;
50
+ const l = Array.isArray(node.logo) ? node.logo[0] : node.logo;
51
+ const url = absoluteUrl(typeof l === 'string' ? l : (l?.url ?? l?.contentUrl), base);
52
+ if (url)
53
+ out.push({ url, source: 'json_ld', score: 4, ...(num(l?.width) ? { width: num(l.width) } : {}), ...(num(l?.height) ? { height: num(l.height) } : {}) });
54
+ }
55
+ const words = (siteTitle ?? '').toLowerCase();
56
+ const scan = (region, source, bonus) => {
57
+ for (const t of tags(region, 'img')) {
58
+ const a = t.attrs;
59
+ const src = a.src || a['data-src'] || (a.srcset ?? a['data-srcset'] ?? '').split(',')[0]?.trim().split(/\s+/)[0];
60
+ // Shopify themes leave {width} in lazy sources.
61
+ const url = absoluteUrl(src?.replace(/\{width\}/g, '600'), base);
62
+ if (!url)
63
+ continue;
64
+ const own = [a.class, a.id, a.alt, src].join(' ').toLowerCase();
65
+ // The element around it (a link or a wrapper named logo), not just anything near.
66
+ const parents = [...region.slice(Math.max(0, t.index - 600), t.index).matchAll(/<(?!img\b|\/)[a-z][^>]*>/gi)].slice(-2).map((m) => m[0]).join(' ').toLowerCase();
67
+ const alt = (a.alt ?? '').trim().toLowerCase();
68
+ const score = (own.includes('logo') ? 3 : 0) + (alt && words && (words.includes(alt) || alt.includes(words.split(/\s[|–—-]\s/)[0])) ? 2 : 0) + (parents.includes('logo') ? 1 : 0);
69
+ if (score === 0)
70
+ continue;
71
+ out.push({ url, source, score: score + bonus, ...(a.alt ? { alt: a.alt.trim() } : {}), ...(num(a.width) ? { width: num(a.width) } : {}), ...(num(a.height) ? { height: num(a.height) } : {}) });
72
+ }
73
+ };
74
+ const header = headerOf(html);
75
+ scan(header, 'header_img', 1);
76
+ scan(html, 'img', 0);
77
+ // An inline <svg> logo has no URL to link: say so, so the agent asks for a file or picks another.
78
+ for (const t of tags(header, 'svg')) {
79
+ const own = [t.attrs.class, t.attrs.id, t.attrs['aria-label']].join(' ').toLowerCase();
80
+ const titled = header.slice(t.index, t.index + 400).match(/<title>([^<]*)<\/title>/i)?.[1]?.toLowerCase();
81
+ if (!own.includes('logo') && !(titled && words && words.includes(titled.trim())))
82
+ continue;
83
+ out.push({ url: null, source: 'header_svg', score: 2, note: 'An inline SVG in the header: it has no URL to link. Use another candidate, or ask the human for the logo file (img/logo.svg).' });
84
+ break;
85
+ }
86
+ const seen = new Set();
87
+ return out
88
+ .sort((a, b) => b.score - a.score)
89
+ .filter((l) => {
90
+ const key = l.url ?? `svg:${l.source}`;
91
+ if (seen.has(key))
92
+ return false;
93
+ seen.add(key);
94
+ return true;
95
+ })
96
+ .slice(0, MAX_LOGOS)
97
+ .map(({ score: _, ...l }) => l);
98
+ }
99
+ const ISO_CURRENCY = /^[A-Z]{3}$/;
100
+ export function currenciesSeen(html, meta) {
101
+ const found = new Set();
102
+ const add = (c) => typeof c === 'string' && ISO_CURRENCY.test(c.trim().toUpperCase()) && found.add(c.trim().toUpperCase());
103
+ add(meta['og:price:currency']);
104
+ add(meta['product:price:currency']);
105
+ for (const m of html.matchAll(/Shopify\.currency\s*=\s*\{[^}]*"active"\s*:\s*"([A-Za-z]{3})"/g))
106
+ add(m[1]);
107
+ for (const m of html.matchAll(/"priceCurrency"\s*:\s*"([A-Za-z]{3})"/g))
108
+ add(m[1]);
109
+ for (const m of html.matchAll(/"currency_code"\s*:\s*"([A-Za-z]{3})"/g))
110
+ add(m[1]);
111
+ for (const m of html.matchAll(/data-currency(?:-code)?\s*=\s*["']([A-Za-z]{3})["']/g))
112
+ add(m[1]);
113
+ // A currency selector: <select name="currency"> with ISO codes as values.
114
+ for (const s of html.matchAll(/<select\b[^>]*name\s*=\s*["']?currency[^>]*>([\s\S]*?)<\/select>/gi))
115
+ for (const o of tags(s[1], 'option'))
116
+ add(o.attrs.value);
117
+ return [...found].sort();
118
+ }
119
+ export function countriesSeen(html, base) {
120
+ const found = new Set();
121
+ const add = (c) => typeof c === 'string' && /^[A-Z]{2}$/.test(c.trim()) && found.add(c.trim());
122
+ for (const h of hreflangs(html, base))
123
+ if (h.region && /^[A-Z]{2}$/.test(h.region))
124
+ found.add(h.region);
125
+ for (const m of html.matchAll(/Shopify\.country\s*=\s*"([A-Z]{2})"/g))
126
+ add(m[1]);
127
+ // A country selector: Shopify's localization form, or a <select name="country...">.
128
+ for (const f of html.matchAll(/<form\b[^>]*localization[^>]*>([\s\S]*?)<\/form>/gi))
129
+ for (const m of f[1].matchAll(/data-value\s*=\s*["']([A-Z]{2})["']/g))
130
+ add(m[1]);
131
+ for (const s of html.matchAll(/<select\b[^>]*name\s*=\s*["']?country[^>]*>([\s\S]*?)<\/select>/gi))
132
+ for (const o of tags(s[1], 'option'))
133
+ add(o.attrs.value);
134
+ return [...found].sort();
135
+ }
136
+ /** The paths catalog pull reads, by strategy: whether robots.txt lets c11r read them. */
137
+ const PRODUCT_PATHS = { shopify: ['/products.json', '/collections/all/products.json'], woocommerce: ['/wp-json/wc/store/v1/products'], structured_data: ['/sitemap.xml'] };
138
+ export async function inspect(raw) {
139
+ if (!raw)
140
+ fail('missing_argument', 'storefront inspect needs the website address.', 'Run c11r storefront inspect https://brand.example.');
141
+ const origin = siteOrigin(raw, 'The address');
142
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
143
+ const start = absoluteUrl(withScheme, origin) ?? `${origin}/`;
144
+ const web = new Web();
145
+ let page;
146
+ try {
147
+ page = await web.get(start);
148
+ }
149
+ catch (e) {
150
+ if (e instanceof BudgetExceeded)
151
+ fail('site_unreachable', `${start} did not answer in time.`, 'Try again later.');
152
+ unreachable(start, e);
153
+ }
154
+ if (page === null)
155
+ fail('robots_disallowed', `${start}'s robots.txt does not let c11r read that page.`, 'c11r does not go around robots.txt. Ask the human for the logo, colours and products instead (a feed or a sheet for catalog pull).');
156
+ if (page.status === 0)
157
+ fail('site_unreachable', `${start} did not answer within ${Math.round(web.timeout / 1000)} s.`, 'Check the address with the human (open it in a browser), and try again later.');
158
+ if (!page.ok)
159
+ fail('site_error', `${start} answered ${page.status}.`, 'Check the address with the human: open it in a browser.');
160
+ const html = page.text;
161
+ const base = page.url;
162
+ const finalOrigin = new URL(base).origin;
163
+ const meta = metas(html);
164
+ const title = oneLine(html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? '') || null;
165
+ const siteName = meta['og:site_name'] || null;
166
+ const lang = html.match(/<html\b[^>]*\blang\s*=\s*["']?([A-Za-z]{2,3}(?:[-_][A-Za-z0-9]+)*)/)?.[1] ?? null;
167
+ const alternates = hreflangs(html, base);
168
+ const languages = [...new Set([lang?.slice(0, 2).toLowerCase(), ...alternates.map((h) => h.language)].filter((l) => !!l))];
169
+ const icons = tags(html, 'link')
170
+ .filter((t) => /\b(icon|apple-touch-icon|mask-icon)\b/i.test(t.attrs.rel ?? ''))
171
+ .map((t) => ({ url: absoluteUrl(t.attrs.href, base), rel: t.attrs.rel, ...(t.attrs.sizes ? { sizes: t.attrs.sizes } : {}), ...(t.attrs.type ? { type: t.attrs.type } : {}) }))
172
+ .filter((i) => i.url);
173
+ // Colours: theme-color first, then the CSS: inline <style>, style="" and the first linked stylesheets.
174
+ const counts = new Map();
175
+ for (const m of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi))
176
+ cssColors(m[1], counts);
177
+ for (const m of html.matchAll(/\sstyle\s*=\s*"([^"]*)"/gi))
178
+ cssColors(m[1], counts);
179
+ const sheets = tags(html, 'link')
180
+ .filter((t) => /\bstylesheet\b/i.test(t.attrs.rel ?? '') && t.attrs.href)
181
+ .map((t) => absoluteUrl(t.attrs.href, base))
182
+ .filter((u) => !!u)
183
+ .slice(0, MAX_STYLESHEETS);
184
+ let stylesheetsRead = 0;
185
+ for (const url of sheets) {
186
+ try {
187
+ const css = await web.get(url, 'text/css,*/*;q=0.1');
188
+ if (css?.ok) {
189
+ cssColors(css.text, counts);
190
+ stylesheetsRead++;
191
+ }
192
+ }
193
+ catch (e) {
194
+ if (e instanceof BudgetExceeded)
195
+ break;
196
+ }
197
+ }
198
+ const themeColor = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(meta['theme-color'] ?? '') ? hex6(meta['theme-color']) : null;
199
+ const colors = [
200
+ ...(themeColor ? [{ hex: themeColor, source: 'theme_color', count: counts.get(themeColor) ?? 0, neutral: neutral(themeColor) }] : []),
201
+ ...[...counts.entries()]
202
+ .filter(([h]) => h !== themeColor)
203
+ .sort((a, b) => b[1] - a[1])
204
+ .map(([hex, count]) => ({ hex, source: 'css', count, neutral: neutral(hex) })),
205
+ ].slice(0, MAX_COLORS);
206
+ const robots = await web.robotsFor(finalOrigin).catch(() => ({ rules: [], sitemaps: [] }));
207
+ const paths = Object.fromEntries(Object.values(PRODUCT_PATHS).flat().map((p) => [p, robotsAllows(robots, p)]));
208
+ const platform = platformOf(html, page.headers);
209
+ const relevant = platform === 'unknown' ? Object.values(PRODUCT_PATHS).flat() : [...PRODUCT_PATHS[platform], ...PRODUCT_PATHS.structured_data];
210
+ return {
211
+ url: base,
212
+ title,
213
+ site_name: siteName,
214
+ description: meta.description || meta['og:description'] || null,
215
+ language: lang,
216
+ languages,
217
+ theme_color: themeColor,
218
+ icons,
219
+ og_image: absoluteUrl(meta['og:image:secure_url'] || meta['og:image'], base),
220
+ logo_candidates: logoCandidates(html, base, siteName ?? title),
221
+ colors,
222
+ currencies_seen: currenciesSeen(html, meta),
223
+ countries_seen: countriesSeen(html, base),
224
+ platform,
225
+ robots: { products_allowed: relevant.some((p) => paths[p]), paths },
226
+ stylesheets_read: stylesheetsRead,
227
+ requests: web.requests,
228
+ next: 'Pick theme.color from colors (the first one that is not neutral, usually) and theme.logo from logo_candidates (its url, hot-linked: nothing is copied), and show the human what you picked. Then catalog: { source: website, url: <this site> } in store.yml and c11r catalog pull.' + (relevant.some((p) => paths[p]) ? '' : ' robots.txt disallows the paths catalog pull reads: ask the human for a feed or a sheet instead.'),
229
+ };
230
+ }
package/dist/store.js CHANGED
@@ -59,6 +59,8 @@ catalog:
59
59
  # # C11R_SUPABASE_URL and C11R_SUPABASE_KEY in .commercelayer/credentials
60
60
  # source: merchant # a Google Merchant Center feed, RSS (XML) or TSV: url is the feed;
61
61
  # # translations: { it: <the it feed url> } adds other languages, by id
62
+ # source: website # a brand's website, for a demo store: url is the site; limit is how
63
+ # # many products (default 100, at most 500). Images stay on the site.
62
64
  # url: https://...
63
65
  # table: products
64
66
 
package/dist/web.js ADDED
@@ -0,0 +1,316 @@
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
+ }
@@ -0,0 +1,390 @@
1
+ // catalog pull, source website: the products of a brand's own site, for a demo store. Three ways
2
+ // in, tried in order, and the first that yields products wins: Shopify's public products.json,
3
+ // WooCommerce's Store API, then the sitemap's product pages and their JSON-LD (schema.org
4
+ // Product). Images are hot-linked, never downloaded: they stay on the brand's servers.
5
+ import { fail } from './output.js';
6
+ import { absoluteUrl, BudgetExceeded, clip, htmlToText, hreflangs, isType, jsonLd, oneLine, platformOf, skuFromHandle, unreachable, Web } from './web.js';
7
+ export const WEBSITE_LIMIT = 100;
8
+ export const WEBSITE_MAX = 500;
9
+ const DESCRIPTION_MAX = 2000;
10
+ const SHOPIFY_PAGE = 250;
11
+ const WOO_PAGE = 100;
12
+ /** Child sitemaps read from a sitemap index, product ones first. */
13
+ const MAX_SITEMAPS = 10;
14
+ const PRODUCT_PATH = /\/(products?|p|shop|item)\/[^/?#]+/i;
15
+ /** What a strategy gathered, and what it left out. */
16
+ class Gathered {
17
+ limit;
18
+ products = [];
19
+ skus = new Set();
20
+ variantsSkipped = 0;
21
+ noPriceSkipped = 0;
22
+ duplicatesSkipped = 0;
23
+ seen = null;
24
+ currency = null;
25
+ constructor(limit) {
26
+ this.limit = limit;
27
+ }
28
+ full() {
29
+ return this.products.length >= this.limit;
30
+ }
31
+ add(f) {
32
+ if (this.full() || !f.name)
33
+ return;
34
+ if (f.price === null || !Number.isFinite(f.price) || f.price < 0) {
35
+ this.noPriceSkipped++;
36
+ return;
37
+ }
38
+ if (!f.sku || this.skus.has(f.sku)) {
39
+ this.duplicatesSkipped++;
40
+ return;
41
+ }
42
+ this.skus.add(f.sku);
43
+ this.products.push(f);
44
+ }
45
+ }
46
+ const text = (html) => {
47
+ const t = typeof html === 'string' ? clip(htmlToText(html), DESCRIPTION_MAX) : '';
48
+ return t || undefined;
49
+ };
50
+ /** A category path from site names: "Coffee > Beans"; empty levels and stray > dropped. */
51
+ const category = (...levels) => {
52
+ const parts = levels
53
+ .flatMap((l) => String(l ?? '').split('>'))
54
+ .map((l) => oneLine(l))
55
+ .filter(Boolean);
56
+ return parts.length ? parts.join(' > ') : undefined;
57
+ };
58
+ const currencyCode = (v) => (typeof v === 'string' && /^[A-Za-z]{3}$/.test(v.trim()) ? v.trim().toUpperCase() : null);
59
+ async function shopify(web, origin, g) {
60
+ let base = `${origin}/products.json`;
61
+ let collection = null;
62
+ let first = await web.json(`${base}?limit=${SHOPIFY_PAGE}&page=1`);
63
+ if (!Array.isArray(first?.body?.products)) {
64
+ const fallback = await web.json(`${origin}/collections/all/products.json?limit=${SHOPIFY_PAGE}&page=1`);
65
+ if (Array.isArray(fallback?.body?.products)) {
66
+ base = `${origin}/collections/all/products.json`;
67
+ collection = 'all';
68
+ first = fallback;
69
+ }
70
+ else
71
+ return { result: first === null && fallback === null ? 'disallowed' : 'not_found' };
72
+ }
73
+ const meta = await web.json(`${origin}/meta.json`).catch((e) => {
74
+ if (e instanceof BudgetExceeded)
75
+ throw e;
76
+ return null;
77
+ });
78
+ g.currency = currencyCode(meta?.body?.currency);
79
+ if (typeof meta?.body?.published_products_count === 'number')
80
+ g.seen = meta.body.published_products_count;
81
+ let listed = 0;
82
+ let page = 1;
83
+ let body = first.body;
84
+ for (;;) {
85
+ const products = body.products;
86
+ listed += products.length;
87
+ for (const p of products) {
88
+ if (g.full())
89
+ break;
90
+ const variants = Array.isArray(p.variants) ? p.variants : [];
91
+ const v = variants.find((x) => x?.available !== false) ?? variants[0];
92
+ if (variants.length > 1)
93
+ g.variantsSkipped += variants.length - 1;
94
+ const tags = Array.isArray(p.tags) ? p.tags : typeof p.tags === 'string' ? p.tags.split(',') : [];
95
+ const tag = tags.map((t) => t.trim()).find((t) => t && !t.startsWith('_') && !t.includes(':'));
96
+ g.add({
97
+ sku: String(v?.sku ?? '').trim() || skuFromHandle(String(p.handle ?? '')),
98
+ name: oneLine(p.title),
99
+ description: text(p.body_html),
100
+ image_url: absoluteUrl(p.images?.[0]?.src, origin) ?? undefined,
101
+ category: category(p.product_type) ?? category(tag) ?? (collection && collection !== 'all' ? category(collection) : undefined),
102
+ price: v && v.price !== undefined && v.price !== null && v.price !== '' ? Number(v.price) : null,
103
+ currency: currencyCode(v?.price_currency ?? v?.price?.currency_code),
104
+ });
105
+ }
106
+ if (g.full() || products.length < SHOPIFY_PAGE)
107
+ break;
108
+ page++;
109
+ const next = await web.json(`${base}?limit=${SHOPIFY_PAGE}&page=${page}`);
110
+ if (!Array.isArray(next?.body?.products) || next.body.products.length === 0)
111
+ break;
112
+ body = next.body;
113
+ }
114
+ g.seen ??= listed;
115
+ g.currency ??= g.products.find((p) => p.currency)?.currency ?? null;
116
+ return g.products.length ? { result: 'products', gathered: g } : { result: 'no_products' };
117
+ }
118
+ async function woocommerce(web, origin, g) {
119
+ const url = (page) => `${origin}/wp-json/wc/store/v1/products?per_page=${WOO_PAGE}&page=${page}`;
120
+ let r = await web.json(url(1));
121
+ if (r === null)
122
+ return { result: 'disallowed' };
123
+ if (!Array.isArray(r.body))
124
+ return { result: 'not_found' };
125
+ const total = Number(r.page.headers.get('x-wp-total'));
126
+ if (Number.isFinite(total) && total > 0)
127
+ g.seen = total;
128
+ let listed = 0;
129
+ for (let page = 1;;) {
130
+ const products = r.body;
131
+ listed += products.length;
132
+ for (const p of products) {
133
+ if (g.full())
134
+ break;
135
+ const prices = p.prices ?? {};
136
+ const minor = Number(prices.currency_minor_unit ?? 2);
137
+ const raw = prices.price;
138
+ const price = raw !== undefined && raw !== null && raw !== '' && Number.isFinite(Number(raw)) ? Number(raw) / 10 ** (Number.isFinite(minor) ? minor : 2) : null;
139
+ if (Array.isArray(p.variations) && p.variations.length > 1)
140
+ g.variantsSkipped += p.variations.length - 1;
141
+ g.add({
142
+ sku: String(p.sku ?? '').trim() || skuFromHandle(String(p.slug ?? p.id ?? '')),
143
+ name: oneLine(p.name),
144
+ description: text(p.description) ?? text(p.short_description),
145
+ image_url: absoluteUrl(p.images?.[0]?.src, origin) ?? undefined,
146
+ category: category(p.categories?.[0]?.name),
147
+ price,
148
+ currency: currencyCode(prices.currency_code),
149
+ });
150
+ }
151
+ if (g.full() || products.length < WOO_PAGE)
152
+ break;
153
+ page++;
154
+ const next = await web.json(url(page));
155
+ if (!Array.isArray(next?.body) || next.body.length === 0)
156
+ break;
157
+ r = next;
158
+ }
159
+ g.seen ??= listed;
160
+ g.currency = g.products.find((p) => p.currency)?.currency ?? null;
161
+ return g.products.length ? { result: 'products', gathered: g } : { result: 'no_products' };
162
+ }
163
+ const locs = (xml) => [...xml.matchAll(/<loc>\s*(?:<!\[CDATA\[)?\s*([^<\]]+?)\s*(?:\]\]>)?\s*<\/loc>/gi)].map((m) => m[1].replace(/&amp;/g, '&'));
164
+ /** The product pages a site's sitemaps list: robots.txt's Sitemap lines, else /sitemap.xml; an index is opened. */
165
+ async function productUrls(web, origin) {
166
+ const robots = await web.robotsFor(origin);
167
+ const roots = robots.sitemaps.length ? robots.sitemaps.slice(0, 3) : [`${origin}/sitemap.xml`];
168
+ const urls = new Set();
169
+ let disallowed = true;
170
+ const queue = [...roots];
171
+ let read = 0;
172
+ while (queue.length && read < MAX_SITEMAPS) {
173
+ const url = queue.shift();
174
+ if (/\.gz($|\?)/i.test(url))
175
+ continue;
176
+ read++;
177
+ const page = await web.get(url, 'application/xml,text/xml;q=0.9,*/*;q=0.5');
178
+ if (page === null)
179
+ continue;
180
+ disallowed = false;
181
+ if (!page.ok)
182
+ continue;
183
+ if (/<sitemapindex\b/i.test(page.text)) {
184
+ // Product sitemaps first (Shopify's sitemap_products_1.xml, WordPress's product-sitemap.xml).
185
+ const children = locs(page.text).sort((a, b) => Number(/product/i.test(b)) - Number(/product/i.test(a)));
186
+ queue.push(...children);
187
+ continue;
188
+ }
189
+ for (const loc of locs(page.text)) {
190
+ const u = absoluteUrl(loc, origin);
191
+ if (u && new URL(u).origin === origin && PRODUCT_PATH.test(new URL(u).pathname))
192
+ urls.add(u);
193
+ }
194
+ }
195
+ return { urls: [...urls], disallowed: disallowed && read > 0 };
196
+ }
197
+ const first = (v) => (Array.isArray(v) ? v[0] : v);
198
+ const imageOf = (v, base) => {
199
+ const i = first(v);
200
+ const url = typeof i === 'string' ? i : (i?.url ?? i?.contentUrl);
201
+ return absoluteUrl(url, base) ?? undefined;
202
+ };
203
+ /** "1.234,50", "1,234.50", "14,50", "14.50": the last separator before 1 or 2 digits is the decimal one. */
204
+ export function amount(raw) {
205
+ const s = raw.replace(/[^\d.,-]/g, '');
206
+ const m = s.match(/^(-?[\d.,]*?)[.,](\d{1,2})$/);
207
+ return m ? Number(`${m[1].replace(/[.,]/g, '') || '0'}.${m[2]}`) : Number(s.replace(/[.,]/g, '') || NaN);
208
+ }
209
+ /** The price of a schema.org offer: Offer, AggregateOffer (lowPrice), or a priceSpecification. */
210
+ function offerPrice(offers) {
211
+ const list = (Array.isArray(offers) ? offers : offers ? [offers] : []);
212
+ for (const o of list) {
213
+ const spec = first(o?.priceSpecification);
214
+ const raw = o?.price ?? o?.lowPrice ?? spec?.price;
215
+ const n = typeof raw === 'number' ? raw : amount(String(raw ?? ''));
216
+ if (raw !== undefined && raw !== null && raw !== '' && Number.isFinite(n))
217
+ return { price: n, currency: currencyCode(o?.priceCurrency ?? spec?.priceCurrency) };
218
+ if (o?.offers) {
219
+ const inner = offerPrice(o.offers);
220
+ if (inner.price !== null)
221
+ return inner;
222
+ }
223
+ }
224
+ return { price: null, currency: null };
225
+ }
226
+ /** A product page's schema.org Product (a ProductGroup's first variant), with the breadcrumb trail as category. */
227
+ export function productFromPage(html, url) {
228
+ const nodes = jsonLd(html);
229
+ const group = nodes.find((n) => isType(n, 'ProductGroup'));
230
+ const product = nodes.find((n) => isType(n, 'Product')) ?? group;
231
+ if (!product)
232
+ return null;
233
+ const variant = group ? first(group.hasVariant) : undefined;
234
+ const offers = offerPrice(product.offers ?? variant?.offers);
235
+ const name = oneLine(product.name ?? group?.name);
236
+ const crumbs = nodes.find((n) => isType(n, 'BreadcrumbList'));
237
+ let trail = [];
238
+ if (Array.isArray(crumbs?.itemListElement)) {
239
+ trail = [...crumbs.itemListElement]
240
+ .sort((a, b) => Number(a?.position ?? 0) - Number(b?.position ?? 0))
241
+ .map((e) => oneLine(e?.name ?? e?.item?.name))
242
+ .filter(Boolean);
243
+ if (trail.length && /^(home|homepage|start|inicio|accueil|startseite)$/i.test(trail[0]))
244
+ trail.shift();
245
+ if (trail.length && trail[trail.length - 1] === name)
246
+ trail.pop();
247
+ }
248
+ const slug = new URL(url).pathname.split('/').filter(Boolean).pop() ?? '';
249
+ return {
250
+ sku: String(product.sku ?? variant?.sku ?? product.productID ?? product.mpn ?? '').trim() || skuFromHandle(decodeURIComponent(slug).replace(/\.html?$/i, '')),
251
+ name,
252
+ description: text(product.description),
253
+ image_url: imageOf(product.image ?? variant?.image, url),
254
+ category: category(...trail) ?? (typeof product.category === 'string' ? category(product.category) : undefined),
255
+ price: offers.price,
256
+ currency: offers.currency,
257
+ };
258
+ }
259
+ async function structuredData(web, origin, g) {
260
+ const { urls, disallowed } = await productUrls(web, origin);
261
+ if (!urls.length)
262
+ return { result: disallowed ? 'disallowed' : 'not_found' };
263
+ g.seen = urls.length;
264
+ const currencies = new Map();
265
+ for (const url of urls) {
266
+ if (g.full())
267
+ break;
268
+ let page;
269
+ try {
270
+ page = await web.get(url);
271
+ }
272
+ catch (e) {
273
+ if (e instanceof BudgetExceeded)
274
+ break;
275
+ continue;
276
+ }
277
+ if (!page?.ok)
278
+ continue;
279
+ const found = productFromPage(page.text, page.url);
280
+ if (!found)
281
+ continue;
282
+ if (found.currency)
283
+ currencies.set(found.currency, (currencies.get(found.currency) ?? 0) + 1);
284
+ g.add(found);
285
+ }
286
+ g.currency = [...currencies.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
287
+ if (g.products.length)
288
+ return { result: 'products', gathered: g };
289
+ return { result: web.budgetExhausted ? 'budget_exhausted' : 'no_products' };
290
+ }
291
+ const STRATEGIES = [
292
+ ['shopify', shopify],
293
+ ['woocommerce', woocommerce],
294
+ ['structured_data', structuredData],
295
+ ];
296
+ /** The site's products as catalog products in its currency, and how they were found. */
297
+ export async function fromWebsite(c, marketCurrencies, currencyFlag) {
298
+ const web = new Web();
299
+ let origin = c.url;
300
+ // The home page: the site's real origin (after a redirect to www), its title, platform and languages.
301
+ let home = null;
302
+ try {
303
+ home = await web.get(`${origin}/`);
304
+ }
305
+ catch (e) {
306
+ if (e instanceof BudgetExceeded)
307
+ throw e;
308
+ unreachable(origin, e);
309
+ }
310
+ if (home && home.status === 0)
311
+ fail('site_unreachable', `${origin} did not answer within ${Math.round(web.timeout / 1000)} s.`, 'Check the address with the human (open it in a browser), and try again later.');
312
+ if (home?.ok)
313
+ origin = new URL(home.url).origin;
314
+ const html = home?.ok ? home.text : '';
315
+ const title = oneLine(html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? '') || null;
316
+ const langs = [html.match(/<html\b[^>]*\blang\s*=\s*["']?([a-zA-Z]{2,3})/)?.[1]?.toLowerCase(), ...hreflangs(html, origin).map((h) => h.language)].filter((l) => !!l);
317
+ const tried = [];
318
+ let won = null;
319
+ for (const [strategy, run] of STRATEGIES) {
320
+ const g = new Gathered(c.limit);
321
+ let outcome;
322
+ try {
323
+ outcome = await run(web, origin, g);
324
+ }
325
+ catch (e) {
326
+ if (!(e instanceof BudgetExceeded))
327
+ throw e;
328
+ outcome = g.products.length ? { result: 'products', gathered: g } : { result: 'budget_exhausted' };
329
+ }
330
+ tried.push({ strategy, result: outcome.result });
331
+ if (outcome.result === 'products') {
332
+ won = { strategy, gathered: outcome.gathered };
333
+ break;
334
+ }
335
+ if (web.budgetExhausted)
336
+ break;
337
+ }
338
+ if (!won) {
339
+ const all = tried.length === STRATEGIES.length && tried.every((t) => t.result === 'disallowed');
340
+ const some = tried.filter((t) => t.result === 'disallowed').map((t) => t.strategy);
341
+ fail('no_products_found', `Found no products on ${origin}: ${tried.map((t) => `${t.strategy} ${t.result.replace('_', ' ')}`).join(', ')}.`, `${all ? "The site's robots.txt does not let c11r read its products, and c11r does not go around it. " : some.length ? `robots.txt disallows ${some.join(' and ')}. ` : ''}${web.budgetExhausted ? 'The site answered too slowly to finish in 60 s. ' : ''}Ask the human for a product feed or a sheet instead (catalog source merchant or sheets, then c11r catalog pull).`);
342
+ }
343
+ const g = won.gathered;
344
+ const flag = currencyFlag?.trim().toUpperCase();
345
+ if (flag !== undefined && !/^[A-Z]{3}$/.test(flag))
346
+ fail('invalid_argument', `--currency is "${currencyFlag}".`, 'Use the ISO 4217 code of the prices on the site, such as EUR.');
347
+ if (flag && g.currency && flag !== g.currency)
348
+ fail('currency_mismatch', `The site says its prices are in ${g.currency}, not ${flag}.`, `Leave out --currency: the site's own currency is ${g.currency}.`);
349
+ const currency = g.currency ?? flag;
350
+ if (!currency)
351
+ fail('currency_unknown', `${origin} does not say which currency its prices are in.`, `Ask the human, then run c11r catalog pull --currency <code> (such as EUR). The prices are taken as they are on the site, never converted.`);
352
+ const others = marketCurrencies.filter((m) => m !== currency);
353
+ if (marketCurrencies.length && others.length) {
354
+ fail('currency_mismatch', `The site's prices are in ${currency}; the store's markets sell in ${marketCurrencies.join(', ')}.`, marketCurrencies.includes(currency)
355
+ ? `A website has one currency: the products would have no ${others.join(', ')} price. Keep only ${currency} markets in store.yml for the demo (ask the human), or add those prices from another source.`
356
+ : `c11r never converts prices. Ask the human whether the demo store sells in ${currency}: if so, set the market's currency to ${currency} in store.yml, then pull again.`);
357
+ }
358
+ let otherCurrency = 0;
359
+ const products = [];
360
+ for (const f of g.products) {
361
+ if (f.currency && f.currency !== currency) {
362
+ otherCurrency++;
363
+ continue;
364
+ }
365
+ const p = { sku: f.sku, name: f.name, prices: { [currency]: Math.round(f.price * 100) / 100 } };
366
+ if (f.description)
367
+ p.description = f.description;
368
+ if (f.image_url)
369
+ p.image_url = f.image_url;
370
+ if (f.category)
371
+ p.category = f.category;
372
+ products.push(p);
373
+ }
374
+ return {
375
+ products,
376
+ report: {
377
+ strategy: won.strategy,
378
+ source_site: { url: origin, title, platform: won.strategy === 'structured_data' ? platformOf(html, home?.headers) : won.strategy, product_count_seen: g.seen, currency, languages: [...new Set(langs)] },
379
+ limit: c.limit,
380
+ variants_skipped: g.variantsSkipped,
381
+ robots_skipped: web.robotsSkipped,
382
+ no_price_skipped: g.noPriceSkipped,
383
+ duplicates_skipped: g.duplicatesSkipped,
384
+ other_currency_skipped: otherCurrency,
385
+ tried,
386
+ requests: web.requests,
387
+ budget_exhausted: web.budgetExhausted,
388
+ },
389
+ };
390
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "c11r: the Commerce Layer CLI for coding agents. Builds a sandbox store that people and shopping agents can buy from.",
5
5
  "homepage": "https://commercelayer.ai",
6
6
  "keywords": [