@rankwrangler/cli 0.10.0 → 0.12.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
@@ -8,18 +8,19 @@ npm install -g @rankwrangler/cli
8
8
  rw auth set ak_...
9
9
  rw product get B0DV53VS61
10
10
  rw product get B0DV53VS61 --shortName
11
- rw product search "retro gardening shirt"
11
+ rw product search "retro gardening shirt" --maxBsr 300000
12
+ rw keyword results "retro gardening shirt"
12
13
  rw product history B0DV53VS61 --bucket week
13
14
  rw keyword get "retro gardening shirt"
14
15
  rw keyword search "retro gardening"
15
- rw keyword history "retro gardening shirt" --rangeDays 90
16
16
  ```
17
17
 
18
- The public command surface is Product `get/search/history`, keyword `get/search/history`, auth,
19
- config, version, and changelog. Product `get/history` and keyword reads wait for policy-current
20
- data and expose no refresh control; Product search retains its separate search contract. Search
21
- data contains `keyword`, `searchedAt`, and compact results with `organicSearchPlacement`. Output is
22
- a JSON `{ ok, data }` envelope; failures use `{ ok: false, error }` on stderr.
18
+ The public command surface is Product `get/search/history`, keyword `get/search/results`,
19
+ auth, config, version, and changelog. Product search reads the collected database immediately and
20
+ supports filters, sorting, and cursor pagination. Keyword results returns dated Amazon search
21
+ results with placement and accepts `--refresh` to replace the observed Search run.
22
+ Product `get/history` and other keyword reads use server-owned freshness policy.
23
+ Output is a JSON `{ ok, data }` envelope; failures use `{ ok: false, error }` on stderr.
23
24
  The CLI does not expose Catalog, Operation, provider status, or polling commands.
24
25
  `--shortName` adds a nullable image-informed `listing.shortName` to a single Product get, waiting
25
26
  up to 20 seconds for generation and returning `null` if it is still running.
package/dist/CHANGELOG.md CHANGED
@@ -2,6 +2,46 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## v0.12.0 - 2026-10-26
6
+
7
+ ### Added
8
+
9
+ - Collect keyword candidates once per Merch ASIN through Amazon Ads and once per tracked keyword
10
+ through autocomplete, sharing cached Jev decisions and popularity-based discovery scheduling.
11
+ - Add a persistent pause/resume control for the keyword pipeline, initialized paused on rollout.
12
+ - Show the live keyword directory with plain status badges, work-priority ordering, BSR popularity
13
+ and refresh cadence in a Products-style table. Record keyword lifecycle events in Logs.
14
+
15
+ ### Changed
16
+
17
+ - Breaking: keyword get/search now read the stored keyword directory; keyword search uses an opaque
18
+ cursor object. Remove Brand Analytics keyword history and its CLI/MCP operation.
19
+ - Retire Search Terms, Brand Analytics report ingestion, metrics and health checks. Migration drops
20
+ obsolete report tables and demand fields while retaining canonical keywords and product evidence.
21
+
22
+ ## v0.11.0 - 2026-10-23
23
+
24
+ ### Changed
25
+
26
+ - Breaking: `product.search` is now a query over the collected catalog, with optional text,
27
+ structured filters, screening metrics, sorting and cursor pagination. It returns
28
+ `items`, `nextCursor` and dated keyword coverage without waiting for external collection.
29
+ - Preserve the previous external search capability as `keyword.results`, a mutation accepting
30
+ `term` and optional `refresh`, with dated Product placements in the existing response shape.
31
+ - Use the same catalog filtering and sorting in the dashboard, CLI and MCP. Dashboard filters
32
+ apply to the full stored catalog rather than only loaded pages.
33
+
34
+ ### Added
35
+
36
+ - Prepare native text-search indexes and a verified official-brand registry. Research search hides
37
+ known registry matches by default while retaining unknown brands. The registry starts empty.
38
+
39
+ - Add adaptive keyword checks, bounded SP-API pagination and dated collection coverage. General
40
+ searches record interest and return immediately; collection runs independently in the background.
41
+ - Protect Keepa capacity with durable request reservations, measured costs and separate work budgets.
42
+ Admit new recurring history selectively while preserving every existing Product's eligibility.
43
+ - Add operator collection status and active/admitted refresh counts.
44
+
5
45
  ## v0.10.0 - 2026-10-20
6
46
 
7
47
  ### Added
@@ -1,8 +1,13 @@
1
1
  import { parseIntegerOption } from './cli-options.js';
2
- const KEYWORD_MARKETPLACE_ID = 'ATVPDKIKX0DER';
3
2
  export const runKeywordCommand = async (command, client, options, fail) => {
4
- if (options.refresh) {
5
- fail('INVALID_INPUT', '--refresh is not supported for keyword reads');
3
+ if (command.verb !== 'results' && options.refresh) {
4
+ fail('INVALID_INPUT', '--refresh is only supported for keyword results');
5
+ }
6
+ if (command.verb === 'results') {
7
+ return await client.keyword.results.mutate({
8
+ term: requireKeyword(command.args, fail, 'keyword results'),
9
+ refresh: Boolean(options.refresh),
10
+ });
6
11
  }
7
12
  if (command.verb === 'get') {
8
13
  return await runKeywordGet(command.args, client, fail);
@@ -10,9 +15,6 @@ export const runKeywordCommand = async (command, client, options, fail) => {
10
15
  if (command.verb === 'search') {
11
16
  return await runKeywordSearch(command.args, client, options, fail);
12
17
  }
13
- if (command.verb === 'history') {
14
- return await runKeywordHistory(command.args, client, options, fail);
15
- }
16
18
  fail('UNKNOWN_COMMAND', 'Unknown Keyword command', {
17
19
  command: `keyword ${command.verb}`,
18
20
  });
@@ -21,21 +23,13 @@ const runKeywordGet = async (args, client, fail) => {
21
23
  const keyword = requireKeyword(args, fail, 'keyword get');
22
24
  return await client.keyword.get.query({
23
25
  keyword,
24
- marketplaceId: KEYWORD_MARKETPLACE_ID,
25
26
  });
26
27
  };
27
28
  const runKeywordSearch = async (args, client, options, fail) => {
28
29
  const text = requireKeyword(args, fail, 'keyword search');
29
30
  return await client.keyword.search.query({
30
31
  text,
31
- marketplaceId: KEYWORD_MARKETPLACE_ID,
32
- cursor: parseIntegerOption({
33
- value: options.cursor,
34
- optionName: 'cursor',
35
- min: 0,
36
- max: 1_000_000,
37
- defaultValue: 0,
38
- }, fail),
32
+ cursor: options.cursor ? parseKeywordCursor(options.cursor, fail) : undefined,
39
33
  limit: parseIntegerOption({
40
34
  value: options.limit,
41
35
  optionName: 'limit',
@@ -43,21 +37,6 @@ const runKeywordSearch = async (args, client, options, fail) => {
43
37
  max: 100,
44
38
  defaultValue: 25,
45
39
  }, fail),
46
- merchOnly: true,
47
- });
48
- };
49
- const runKeywordHistory = async (args, client, options, fail) => {
50
- const keyword = requireKeyword(args, fail, 'keyword history');
51
- return await client.keyword.history.query({
52
- keyword,
53
- marketplaceId: KEYWORD_MARKETPLACE_ID,
54
- rangeDays: parseIntegerOption({
55
- value: options.rangeDays ?? options.days,
56
- optionName: 'rangeDays',
57
- min: 7,
58
- max: 365,
59
- defaultValue: 90,
60
- }, fail),
61
40
  });
62
41
  };
63
42
  const requireKeyword = (args, fail, commandName) => {
@@ -67,3 +46,17 @@ const requireKeyword = (args, fail, commandName) => {
67
46
  }
68
47
  return keyword;
69
48
  };
49
+ const parseKeywordCursor = (value, fail) => {
50
+ try {
51
+ const parsed = JSON.parse(value);
52
+ const key = parsed?.key;
53
+ if (!Array.isArray(key) || key.length !== 7 || !key.slice(0, 6).every(part => typeof part === 'number' && Number.isFinite(part))
54
+ || typeof key[6] !== 'string' || typeof parsed.asOf !== 'string' || typeof parsed.trialTurn !== 'boolean'
55
+ || (parsed.sort !== 'work' && parsed.sort !== 'newest'))
56
+ return fail('INVALID_INPUT', '--cursor must be the JSON nextCursor from keyword search');
57
+ return { key: [key[0], key[1], key[2], key[3], key[4], key[5], key[6]], asOf: parsed.asOf, trialTurn: parsed.trialTurn, sort: parsed.sort };
58
+ }
59
+ catch {
60
+ return fail('INVALID_INPUT', '--cursor must be valid JSON');
61
+ }
62
+ };
@@ -2,6 +2,28 @@ import type { CliConfig } from './cli-config';
2
2
  import { type HistoryBucket, type HistoryMetricAlias } from './history-options';
3
3
  type CliFail = (code: string, message: string, details?: unknown) => never;
4
4
  export interface CliOptionValues {
5
+ textFields?: string;
6
+ excludeWord?: string[];
7
+ brand?: string[];
8
+ excludeBrand?: string[];
9
+ categoryId?: string[];
10
+ facet?: string[];
11
+ includeOfficialBrands?: boolean;
12
+ merch?: string;
13
+ listingStatus?: string;
14
+ sort?: string;
15
+ minBsr?: string;
16
+ maxBsr?: string;
17
+ minPrice?: string;
18
+ maxPrice?: string;
19
+ minMonthlySold?: string;
20
+ maxMonthlySold?: string;
21
+ updatedAfter?: string;
22
+ updatedBefore?: string;
23
+ availableAfter?: string;
24
+ availableBefore?: string;
25
+ discoveredAfter?: string;
26
+ discoveredBefore?: string;
5
27
  baseUrl?: string;
6
28
  marketplace?: string;
7
29
  metrics?: string;
@@ -1,7 +1,8 @@
1
+ import { runProductSearch } from './cli-product-search.js';
1
2
  import { parseIntegerOption, requireAsins, requireMarketplaceId, requireSingleAsin, resolveHistoryBucket, resolveHistoryMetrics, resolveHistoryWindow, } from './cli-options.js';
2
3
  export const runProductCommand = async (command, client, config, options, fail) => {
3
- if (command.verb !== 'search' && options.refresh) {
4
- fail('INVALID_INPUT', '--refresh is only supported for product search');
4
+ if (options.refresh) {
5
+ fail('INVALID_INPUT', '--refresh is only supported for keyword results');
5
6
  }
6
7
  if (command.verb !== 'get' && options.shortName) {
7
8
  fail('INVALID_INPUT', '--shortName is only supported for product get');
@@ -10,7 +11,7 @@ export const runProductCommand = async (command, client, config, options, fail)
10
11
  return await runProductGet(command.args, client, config, options, fail);
11
12
  }
12
13
  if (command.verb === 'search') {
13
- return await runProductSearch(command.args, client, options, fail);
14
+ return await runProductSearch(command.args, client, config, options, fail);
14
15
  }
15
16
  if (command.verb === 'history') {
16
17
  return await runProductHistory(command.args, client, config, options, fail);
@@ -36,16 +37,6 @@ const runProductGet = async (args, client, config, options, fail) => {
36
37
  products: asins.map(asin => ({ marketplaceId, asin })),
37
38
  });
38
39
  };
39
- const runProductSearch = async (args, client, options, fail) => {
40
- const term = args.join(' ').trim();
41
- if (!term) {
42
- fail('INVALID_INPUT', 'product search requires a keyword');
43
- }
44
- return await client.product.search.mutate({
45
- term,
46
- refresh: Boolean(options.refresh),
47
- });
48
- };
49
40
  const runProductHistory = async (args, client, config, options, fail) => {
50
41
  const asin = requireSingleAsin(args, fail, 'product history');
51
42
  const historyOptions = resolveHistoryOptions(options, fail);
@@ -0,0 +1,6 @@
1
+ import type { createRankWranglerClient } from '@rankwrangler/http-client';
2
+ import type { CliConfig } from './cli-config';
3
+ import { type CliOptionValues } from './cli-options';
4
+ type CliFail = (code: string, message: string, details?: unknown) => never;
5
+ export declare const runProductSearch: (args: string[], client: ReturnType<typeof createRankWranglerClient>, config: CliConfig, options: CliOptionValues, fail: CliFail) => Promise<unknown>;
6
+ export {};
@@ -0,0 +1,99 @@
1
+ import { requireMarketplaceId } from './cli-options.js';
2
+ export const runProductSearch = async (args, client, config, options, fail) => {
3
+ const term = args.join(' ').trim();
4
+ const merch = choice(options.merch, ['true', 'false', 'unknown', 'all'], 'merch', fail);
5
+ return await client.product.search.query({
6
+ ...(term ? { term } : {}),
7
+ marketplaceIds: [requireMarketplaceId(options, config)],
8
+ textFields: choice(options.textFields, ['all', 'title'], 'textFields', fail),
9
+ excludeWords: options.excludeWord,
10
+ brands: options.brand,
11
+ excludeBrands: options.excludeBrand,
12
+ categoryIds: options.categoryId?.map(value => integer(value, 'categoryId', 1, fail)),
13
+ facets: options.facet?.map(value => {
14
+ const separator = value.indexOf('=');
15
+ if (separator < 1 || separator === value.length - 1) {
16
+ fail('INVALID_INPUT', '--facet must be facet=name');
17
+ }
18
+ return {
19
+ facet: value.slice(0, separator),
20
+ name: value.slice(separator + 1),
21
+ };
22
+ }),
23
+ includeOfficialBrands: options.includeOfficialBrands,
24
+ isMerchListing: merch === undefined
25
+ ? undefined
26
+ : merch === 'unknown' || merch === 'all'
27
+ ? merch
28
+ : merch === 'true',
29
+ amazonListingStatus: choice(options.listingStatus, ['active', 'deleted', 'all'], 'listingStatus', fail),
30
+ salesRank: range(options.minBsr, options.maxBsr, 'Bsr', fail),
31
+ price: range(options.minPrice, options.maxPrice, 'Price', fail),
32
+ monthlySold: range(options.minMonthlySold, options.maxMonthlySold, 'MonthlySold', fail),
33
+ firstAvailableAt: dates(options.availableAfter, options.availableBefore, 'available', fail),
34
+ updatedAt: dates(options.updatedAfter, options.updatedBefore, 'updated', fail),
35
+ discoveredAt: dates(options.discoveredAfter, options.discoveredBefore, 'discovered', fail),
36
+ sort: choice(options.sort, [
37
+ 'relevance',
38
+ 'salesRankAsc',
39
+ 'salesRankDesc',
40
+ 'firstAvailableAtDesc',
41
+ 'firstAvailableAtAsc',
42
+ 'updatedAtAsc',
43
+ 'updatedAtDesc',
44
+ 'discoveredAtDesc',
45
+ 'priceAsc',
46
+ 'priceDesc',
47
+ 'monthlySoldDesc',
48
+ ], 'sort', fail),
49
+ limit: options.limit === undefined ? undefined : integer(options.limit, 'limit', 1, fail, 100),
50
+ cursor: options.cursor,
51
+ });
52
+ };
53
+ const choice = (value, choices, name, fail) => {
54
+ if (value === undefined)
55
+ return undefined;
56
+ const match = choices.find(candidate => candidate === value);
57
+ if (match === undefined)
58
+ fail('INVALID_INPUT', `--${name} must be ${choices.join(', ')}`);
59
+ return match;
60
+ };
61
+ const integer = (value, name, min, fail, max = Number.MAX_SAFE_INTEGER) => {
62
+ const parsed = Number(value);
63
+ if (!value.trim() || !Number.isSafeInteger(parsed) || parsed < min || parsed > max) {
64
+ fail('INVALID_INPUT', `--${name} must be an integer between ${min} and ${max}`);
65
+ }
66
+ return parsed;
67
+ };
68
+ const range = (min, max, name, fail) => {
69
+ if (min === undefined && max === undefined)
70
+ return undefined;
71
+ const bounds = {
72
+ ...(min === undefined ? {} : { min: integer(min, `min${name}`, 0, fail) }),
73
+ ...(max === undefined ? {} : { max: integer(max, `max${name}`, 0, fail) }),
74
+ };
75
+ if (bounds.min !== undefined && bounds.max !== undefined && bounds.min > bounds.max) {
76
+ fail('INVALID_INPUT', `--min${name} must not exceed --max${name}`);
77
+ }
78
+ return bounds;
79
+ };
80
+ const dates = (after, before, name, fail) => {
81
+ if (after === undefined && before === undefined)
82
+ return undefined;
83
+ const parse = (value) => {
84
+ if (!/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?Z)?$/.test(value) ||
85
+ !Number.isFinite(new Date(value).getTime()) ||
86
+ new Date(value).toISOString().slice(0, 10) !== value.slice(0, 10)) {
87
+ fail('INVALID_INPUT', `--${name}After and --${name}Before require ISO dates`);
88
+ }
89
+ return new Date(value).toISOString();
90
+ };
91
+ const bounds = {
92
+ after: after === undefined ? undefined : parse(after),
93
+ before: before === undefined ? undefined : parse(before),
94
+ };
95
+ if (bounds.after && bounds.before && bounds.after > bounds.before) {
96
+ fail('INVALID_INPUT', `--${name}After must not exceed --${name}Before`);
97
+ }
98
+ return bounds;
99
+ };
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ const SUPPORTED_COMMANDS = new Set([
16
16
  'product:history',
17
17
  'keyword:get',
18
18
  'keyword:search',
19
- 'keyword:history',
19
+ 'keyword:results',
20
20
  'auth:status',
21
21
  'auth:set',
22
22
  'auth:clear',
@@ -37,12 +37,33 @@ const { positionals, values } = parseArgs({
37
37
  startAt: { type: 'string' },
38
38
  endAt: { type: 'string' },
39
39
  days: { type: 'string' },
40
- rangeDays: { type: 'string' },
41
40
  limit: { type: 'string' },
42
41
  bucket: { type: 'string' },
43
42
  cursor: { type: 'string' },
44
43
  refresh: { type: 'boolean' },
45
44
  shortName: { type: 'boolean' },
45
+ textFields: { type: 'string' },
46
+ excludeWord: { type: 'string', multiple: true },
47
+ brand: { type: 'string', multiple: true },
48
+ excludeBrand: { type: 'string', multiple: true },
49
+ categoryId: { type: 'string', multiple: true },
50
+ facet: { type: 'string', multiple: true },
51
+ includeOfficialBrands: { type: 'boolean' },
52
+ merch: { type: 'string' },
53
+ listingStatus: { type: 'string' },
54
+ sort: { type: 'string' },
55
+ minBsr: { type: 'string' },
56
+ maxBsr: { type: 'string' },
57
+ minPrice: { type: 'string' },
58
+ maxPrice: { type: 'string' },
59
+ minMonthlySold: { type: 'string' },
60
+ maxMonthlySold: { type: 'string' },
61
+ updatedAfter: { type: 'string' },
62
+ updatedBefore: { type: 'string' },
63
+ availableAfter: { type: 'string' },
64
+ availableBefore: { type: 'string' },
65
+ discoveredAfter: { type: 'string' },
66
+ discoveredBefore: { type: 'string' },
46
67
  stdin: { type: 'boolean' },
47
68
  },
48
69
  allowPositionals: true,
@@ -122,7 +143,9 @@ const resolveCommandOrFail = (inputPositionals) => {
122
143
  if (command) {
123
144
  return command;
124
145
  }
125
- fail('UNKNOWN_COMMAND', 'Unknown command', { command: inputPositionals.join(' ') });
146
+ fail('UNKNOWN_COMMAND', 'Unknown command', {
147
+ command: inputPositionals.join(' '),
148
+ });
126
149
  throw new Error('unreachable');
127
150
  };
128
151
  const isSupportedCommand = (command) => SUPPORTED_COMMANDS.has(`${command.resource}:${command.verb}`);
package/dist/usage.js CHANGED
@@ -16,11 +16,11 @@ export const printUsage = (paths) => {
16
16
  'COMMANDS',
17
17
  ' changelog',
18
18
  ' product get <ASIN...>',
19
- ' product search <keyword>',
19
+ ' product search [text]',
20
20
  ' product history <ASIN>',
21
21
  ' keyword get <keyword>',
22
22
  ' keyword search <text>',
23
- ' keyword history <keyword>',
23
+ ' keyword results <keyword>',
24
24
  ' auth status',
25
25
  ' auth set [apiKey]',
26
26
  ' auth set --stdin',
@@ -38,16 +38,30 @@ export const printUsage = (paths) => {
38
38
  ' --version Print the CLI version',
39
39
  ' --baseUrl <origin> Override API origin',
40
40
  ` -m, --marketplace <id> Override marketplace (default: ${DEFAULT_MARKETPLACE_ID})`,
41
- ' --refresh Product search refresh (separate Search contract)',
41
+ ' --refresh Replace the Search run for keyword results',
42
42
  ' --shortName Generate a design name for one Merch Product',
43
43
  ' --metrics <list> Product history metrics: salesRank,price (default: salesRank,price)',
44
44
  ' --bucket <unit> Product history buckets: auto,day,week,month (default: auto)',
45
45
  ' --days <N> Product history lookback (30-3650, default: 365)',
46
- ' --rangeDays <N> Keyword history lookback (7-365, default: 90)',
47
46
  ' --startAt <ISO> Product history range start (ISO date/time)',
48
47
  ' --endAt <ISO> Product history range end (ISO date/time)',
49
48
  ' --limit <N> Result limit',
50
- ' --cursor <N> Keyword search offset (default: 0)',
49
+ ' --cursor <value> Product search cursor or keyword nextCursor JSON',
50
+ ' --sort <order> Product search order (default: relevance)',
51
+ ' --textFields <scope> Product search text: all,title (default: all)',
52
+ ' --minBsr/--maxBsr <N> Product sales-rank bounds',
53
+ ' --minPrice/--maxPrice <N> Product price bounds in minor currency units',
54
+ ' --minMonthlySold/--maxMonthlySold <N> Product demand bounds',
55
+ ' --availableAfter/--availableBefore <ISO> First-available bounds',
56
+ ' --discoveredAfter/--discoveredBefore <ISO> Discovery bounds',
57
+ ' --updatedAfter/--updatedBefore <ISO> Product update bounds',
58
+ ' --brand/--excludeBrand <name> Repeat for brand filters',
59
+ ' --excludeWord <word> Repeat for excluded words',
60
+ ' --categoryId <N> Repeat for category filters',
61
+ ' --facet <facet=name> Repeat for facet filters',
62
+ ' --merch <value> true,false,unknown,all (default: true)',
63
+ ' --listingStatus <value> active,deleted,all (default: active)',
64
+ ' --includeOfficialBrands Include known official brands in Product search',
51
65
  ' --stdin Read auth secret from standard input',
52
66
  '',
53
67
  'FILES',
@@ -66,11 +80,11 @@ export const printUsage = (paths) => {
66
80
  ' printf %s "$MERCHBASE_API_KEY" | rw auth set --stdin',
67
81
  ` ${API_KEY_ENV_VAR}=ak_... rw product get B0DV53VS61`,
68
82
  ' rw product get B0DV53VS61 B012345678',
69
- ' rw product search "retro gardening shirt"',
83
+ ' rw product search "retro gardening shirt" --maxBsr 300000 --sort salesRankAsc',
84
+ ' rw keyword results "retro gardening shirt"',
70
85
  ' rw product history B0DV53VS61 --bucket week',
71
86
  ' rw keyword get "retro gardening shirt"',
72
87
  ' rw keyword search "retro gardening" --limit 10',
73
- ' rw keyword history "retro gardening shirt" --rangeDays 90',
74
88
  ' RR_STORAGE_DIR=~/.config/rankwrangler rw config show',
75
89
  ' rw --version',
76
90
  ' rw changelog',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rankwrangler/cli",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Official RankWrangler command line interface",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@merchbaseco/access": "0.4.0",
33
- "@rankwrangler/http-client": "^0.10.0",
33
+ "@rankwrangler/http-client": "^0.12.0",
34
34
  "keytar": "7.9.0"
35
35
  },
36
36
  "devDependencies": {