@reactionary/feeds 0.10.41 → 0.10.43
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 +75 -1
- package/cli.cjs +56144 -55959
- package/cli.js +33 -0
- package/index.js +1 -0
- package/lib/acp-feed-publisher.js +141 -0
- package/lib/default-registry.js +2 -1
- package/lib/transformers/acp-product-feed.transformer.js +100 -88
- package/package.json +9 -9
- package/src/index.d.ts +1 -0
- package/src/lib/acp-feed-publisher.d.ts +73 -0
- package/src/lib/feed-types.d.ts +5 -0
- package/src/lib/transformers/acp-product-feed.transformer.d.ts +77 -0
package/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ import { mkdir } from "node:fs/promises";
|
|
|
5
5
|
import { dirname, resolve } from "node:path";
|
|
6
6
|
import { finished } from "node:stream/promises";
|
|
7
7
|
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { createACPFeedPublisherFromConfig } from "./lib/acp-feed-publisher.js";
|
|
8
9
|
import { createDefaultFeedRegistry } from "./lib/default-registry.js";
|
|
9
10
|
import { ReactionaryFeedGenerator } from "./lib/feed-generator.js";
|
|
10
11
|
async function main() {
|
|
@@ -26,6 +27,17 @@ async function main() {
|
|
|
26
27
|
).option("--product-concurrency <count>", "Products to normalize in parallel. Defaults to config value or 10.").option("--testMode", "Limit source reads to at most 2 pages for smoke testing.").option("--no-progress", "Disable progress reporting on stderr.").action(async (options) => {
|
|
27
28
|
await generateFeed(options);
|
|
28
29
|
});
|
|
30
|
+
program.command("publish-acp").description("Push a feed to the agent-hosted ACP Feed API (PATCH /feeds/{id}/products). Run it from cron or a scheduled job.").requiredOption("-c, --config <path>", "Path to an ESM feed config module.").requiredOption("-f, --feed <id>", "Configured feed id to publish; its acp.feedId is the agent feed.").option("--batch-size <count>", "Products per upsert request. Defaults to 100.").option("--testMode", "Limit source reads to at most 2 pages for smoke testing.").option("--no-progress", "Disable progress reporting on stderr.").action(async (options) => {
|
|
31
|
+
const publisher = await createAcpPublisher(options);
|
|
32
|
+
const result = await publisher.publish(options.feed);
|
|
33
|
+
console.log(JSON.stringify(result));
|
|
34
|
+
});
|
|
35
|
+
program.command("create-acp-feed").description("Create a feed on the agent-hosted ACP Feed API (POST /feeds) and print its id, to put into the feed's acp.feedId. Only needed when the agent did not hand out a feed id.").requiredOption("-c, --config <path>", "Path to an ESM feed config module.").requiredOption("-f, --feed <id>", "Configured feed id to create an agent feed for.").action(async (options) => {
|
|
36
|
+
const publisher = await createAcpPublisher({ ...options, progress: false });
|
|
37
|
+
const metadata = await publisher.createFeed(options.feed);
|
|
38
|
+
console.log(JSON.stringify(metadata));
|
|
39
|
+
console.error(`Set acp.feedId to "${metadata.id}" on feed "${options.feed}" in ${options.config}.`);
|
|
40
|
+
});
|
|
29
41
|
await program.parseAsync(process.argv);
|
|
30
42
|
}
|
|
31
43
|
async function generateFeed(options) {
|
|
@@ -51,6 +63,27 @@ async function generateFeed(options) {
|
|
|
51
63
|
});
|
|
52
64
|
}));
|
|
53
65
|
}
|
|
66
|
+
async function createAcpPublisher(options) {
|
|
67
|
+
const config = await loadConfig(options.config);
|
|
68
|
+
return createACPFeedPublisherFromConfig(config, await getClientFactory(config), {
|
|
69
|
+
...options.batchSize ? { batchSize: parseBatchSize(options.batchSize) } : {},
|
|
70
|
+
testMode: Boolean(options.testMode),
|
|
71
|
+
...options.progress === false ? {} : {
|
|
72
|
+
onProgress: createCliProgressReporter({
|
|
73
|
+
feedId: options.feed,
|
|
74
|
+
transformerId: "acp-feed-api",
|
|
75
|
+
stream: process.stderr
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function parseBatchSize(value) {
|
|
81
|
+
const parsed = Number(value);
|
|
82
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
83
|
+
throw new Error(`--batch-size must be a positive integer, got: ${value}`);
|
|
84
|
+
}
|
|
85
|
+
return parsed;
|
|
86
|
+
}
|
|
54
87
|
async function createFeedGenerationContext(options, labels) {
|
|
55
88
|
const config = await loadConfig(options.config);
|
|
56
89
|
const registry = createDefaultFeedRegistry();
|
package/index.js
CHANGED
|
@@ -11,3 +11,4 @@ export * from "./lib/transformers/acp-product-feed.transformer.js";
|
|
|
11
11
|
export * from "./lib/transformers/google-merchant-feed.transformer.js";
|
|
12
12
|
export * from "./lib/transformers/pricerunner-feed.transformer.js";
|
|
13
13
|
export * from "./lib/transformers/sitemap-feed.transformer.js";
|
|
14
|
+
export * from "./lib/acp-feed-publisher.js";
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createInitialRequestContext } from "@reactionary/core";
|
|
2
|
+
import { ReactionaryFeedGenerator } from "./feed-generator.js";
|
|
3
|
+
import {
|
|
4
|
+
toACPFeedProduct
|
|
5
|
+
} from "./transformers/acp-product-feed.transformer.js";
|
|
6
|
+
import * as z from "zod";
|
|
7
|
+
const ACP_FEED_API_VERSION = "2026-04-17";
|
|
8
|
+
const DEFAULT_ACP_FEED_API_KEY_ENV = "ACP_FEED_API_KEY";
|
|
9
|
+
const DEFAULT_BATCH_SIZE = 100;
|
|
10
|
+
const REQUEST_TIMEOUT_MS = 3e4;
|
|
11
|
+
const ACPFeedMetadataSchema = z.looseObject({
|
|
12
|
+
id: z.string(),
|
|
13
|
+
target_country: z.string().optional(),
|
|
14
|
+
updated_at: z.string().optional()
|
|
15
|
+
});
|
|
16
|
+
const ACPFeedUpsertResponseSchema = z.looseObject({
|
|
17
|
+
id: z.string().optional(),
|
|
18
|
+
accepted: z.boolean()
|
|
19
|
+
});
|
|
20
|
+
class ReactionaryACPFeedPublisher {
|
|
21
|
+
constructor(clientFactory, options) {
|
|
22
|
+
this.clientFactory = clientFactory;
|
|
23
|
+
this.options = options;
|
|
24
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
25
|
+
}
|
|
26
|
+
clientFactory;
|
|
27
|
+
options;
|
|
28
|
+
fetchImpl;
|
|
29
|
+
/** `POST /feeds`: creates a feed on the agent for the definition's market. */
|
|
30
|
+
async createFeed(feedKey) {
|
|
31
|
+
const feed = this.getFeed(feedKey);
|
|
32
|
+
const [, region] = feed.languageContext.locale.split("-");
|
|
33
|
+
const response = await this.request("POST", "/feeds", region ? { target_country: region.toUpperCase() } : {});
|
|
34
|
+
return ACPFeedMetadataSchema.parse(response);
|
|
35
|
+
}
|
|
36
|
+
/** `GET /feeds/{id}`: the agent's metadata for a feed. */
|
|
37
|
+
async getFeedMetadata(feedId) {
|
|
38
|
+
return ACPFeedMetadataSchema.parse(await this.request("GET", `/feeds/${encodeURIComponent(feedId)}`));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Generates the feed's products and upserts them, in batches, into the
|
|
42
|
+
* agent-hosted feed with `PATCH /feeds/{feedId}/products`. The feed id
|
|
43
|
+
* defaults to the definition's `acp.feedId`, as handed out by the agent's
|
|
44
|
+
* onboarding. Upserts never remove products; to drop products, replace
|
|
45
|
+
* the feed by file ingestion.
|
|
46
|
+
*/
|
|
47
|
+
async publish(feedKey, feedId) {
|
|
48
|
+
const feed = this.getFeed(feedKey);
|
|
49
|
+
const targetFeedId = feedId ?? feed.acp?.feedId;
|
|
50
|
+
if (!targetFeedId) {
|
|
51
|
+
throw new Error(`ACP feed ${feedKey} has no feed id; configure acp.feedId (from the agent's onboarding) or create one with create-acp-feed.`);
|
|
52
|
+
}
|
|
53
|
+
const requestContext = createInitialRequestContext();
|
|
54
|
+
requestContext.languageContext = feed.languageContext;
|
|
55
|
+
const generator = new ReactionaryFeedGenerator(this.clientFactory(requestContext), {
|
|
56
|
+
defaultFulfillmentCenterKeys: this.options.defaultFulfillmentCenterKeys,
|
|
57
|
+
productConcurrency: this.options.productConcurrency,
|
|
58
|
+
onProgress: this.options.onProgress
|
|
59
|
+
});
|
|
60
|
+
const batchSize = Math.max(this.options.batchSize ?? DEFAULT_BATCH_SIZE, 1);
|
|
61
|
+
const path = `/feeds/${encodeURIComponent(targetFeedId)}/products`;
|
|
62
|
+
let batch = [];
|
|
63
|
+
let products = 0;
|
|
64
|
+
let batches = 0;
|
|
65
|
+
const flush = async () => {
|
|
66
|
+
if (batch.length === 0) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const response = ACPFeedUpsertResponseSchema.parse(await this.request("PATCH", path, { products: batch }));
|
|
70
|
+
if (!response.accepted) {
|
|
71
|
+
throw new Error(`ACP feed ${targetFeedId}: the agent did not accept the product upsert.`);
|
|
72
|
+
}
|
|
73
|
+
products += batch.length;
|
|
74
|
+
batches += 1;
|
|
75
|
+
batch = [];
|
|
76
|
+
};
|
|
77
|
+
for await (const product of generator.products(feed, requestContext)) {
|
|
78
|
+
const acpProduct = toACPFeedProduct(product, feed);
|
|
79
|
+
if (acpProduct) {
|
|
80
|
+
batch.push(acpProduct);
|
|
81
|
+
}
|
|
82
|
+
if (batch.length >= batchSize) {
|
|
83
|
+
await flush();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
await flush();
|
|
87
|
+
return { feedId: targetFeedId, products, batches };
|
|
88
|
+
}
|
|
89
|
+
getFeed(feedKey) {
|
|
90
|
+
const feed = this.options.feeds[feedKey];
|
|
91
|
+
if (!feed) {
|
|
92
|
+
throw new Error(`ACP feed definition not found: ${feedKey}`);
|
|
93
|
+
}
|
|
94
|
+
return feed;
|
|
95
|
+
}
|
|
96
|
+
async request(method, path, body) {
|
|
97
|
+
const url = `${this.options.feedApiBaseUrl.replace(/\/+$/, "")}${path}`;
|
|
98
|
+
const response = await this.fetchImpl(url, {
|
|
99
|
+
method,
|
|
100
|
+
headers: {
|
|
101
|
+
authorization: `Bearer ${this.options.apiKey}`,
|
|
102
|
+
"api-version": ACP_FEED_API_VERSION,
|
|
103
|
+
accept: "application/json",
|
|
104
|
+
...body === void 0 ? {} : { "content-type": "application/json", "idempotency-key": crypto.randomUUID() }
|
|
105
|
+
},
|
|
106
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) },
|
|
107
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
108
|
+
});
|
|
109
|
+
const text = await response.text();
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
throw new Error(`ACP Feed API ${method} ${path} failed with HTTP ${response.status}: ${text.slice(0, 500)}`);
|
|
112
|
+
}
|
|
113
|
+
return text ? JSON.parse(text) : {};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function createACPFeedPublisherFromConfig(config, clientFactory, options = {}) {
|
|
117
|
+
if (!config.acpFeedApi?.baseUrl) {
|
|
118
|
+
throw new Error("The feeds config needs acpFeedApi.baseUrl: the agent's Feed API base URL from onboarding.");
|
|
119
|
+
}
|
|
120
|
+
const apiKeyEnv = config.acpFeedApi.apiKeyEnv ?? DEFAULT_ACP_FEED_API_KEY_ENV;
|
|
121
|
+
const apiKey = (options.env ?? process.env)[apiKeyEnv];
|
|
122
|
+
if (!apiKey) {
|
|
123
|
+
throw new Error(`The ACP Feed API key is missing; set the ${apiKeyEnv} environment variable.`);
|
|
124
|
+
}
|
|
125
|
+
const feeds = options.testMode ? Object.fromEntries(Object.entries(config.feeds).map(([key, feed]) => [key, { ...feed, maxPages: Math.min(feed.maxPages ?? 2, 2) }])) : config.feeds;
|
|
126
|
+
return new ReactionaryACPFeedPublisher(clientFactory, {
|
|
127
|
+
feedApiBaseUrl: config.acpFeedApi.baseUrl,
|
|
128
|
+
apiKey,
|
|
129
|
+
feeds,
|
|
130
|
+
defaultFulfillmentCenterKeys: config.defaultFulfillmentCenterKeys,
|
|
131
|
+
productConcurrency: config.productConcurrency,
|
|
132
|
+
...options.batchSize ? { batchSize: options.batchSize } : {},
|
|
133
|
+
...options.fetch ? { fetch: options.fetch } : {},
|
|
134
|
+
...options.onProgress ? { onProgress: options.onProgress } : {}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
export {
|
|
138
|
+
DEFAULT_ACP_FEED_API_KEY_ENV,
|
|
139
|
+
ReactionaryACPFeedPublisher,
|
|
140
|
+
createACPFeedPublisherFromConfig
|
|
141
|
+
};
|
package/lib/default-registry.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ReactionaryFeedTransformerRegistry
|
|
3
3
|
} from "./feed-transformer.js";
|
|
4
|
-
import { acpProductFeedTransformer } from "./transformers/acp-product-feed.transformer.js";
|
|
4
|
+
import { acpFeedMetadataTransformer, acpProductFeedTransformer } from "./transformers/acp-product-feed.transformer.js";
|
|
5
5
|
import { googleMerchantFeedTransformer } from "./transformers/google-merchant-feed.transformer.js";
|
|
6
6
|
import { pricerunnerFeedTransformer } from "./transformers/pricerunner-feed.transformer.js";
|
|
7
7
|
import { sitemapFeedTransformer } from "./transformers/sitemap-feed.transformer.js";
|
|
8
8
|
function createDefaultFeedRegistry() {
|
|
9
9
|
const registry = new ReactionaryFeedTransformerRegistry();
|
|
10
10
|
registry.register(acpProductFeedTransformer);
|
|
11
|
+
registry.register(acpFeedMetadataTransformer);
|
|
11
12
|
registry.register(googleMerchantFeedTransformer);
|
|
12
13
|
registry.register(sitemapFeedTransformer);
|
|
13
14
|
registry.register(pricerunnerFeedTransformer);
|
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
getTargetCountry
|
|
3
3
|
} from "../feed-generator.js";
|
|
4
|
-
import {
|
|
5
|
-
formatMoney,
|
|
6
|
-
primaryImage,
|
|
7
|
-
productDescription
|
|
8
|
-
} from "./shared.js";
|
|
9
4
|
const acpProductFeedTransformer = {
|
|
10
5
|
id: "acp-product-feed",
|
|
11
6
|
title: "ACP Product Feed",
|
|
12
|
-
description: "Agentic Commerce Protocol
|
|
7
|
+
description: "Agentic Commerce Protocol Feed API products (2026-04-17).",
|
|
13
8
|
output: {
|
|
14
9
|
contentType: "application/x-ndjson; charset=utf-8",
|
|
15
10
|
fileExtension: "jsonl"
|
|
@@ -21,108 +16,125 @@ const acpProductFeedTransformer = {
|
|
|
21
16
|
if (context.options.format === "json") {
|
|
22
17
|
const collected = [];
|
|
23
18
|
for await (const product of products) {
|
|
24
|
-
|
|
19
|
+
const acpProduct = toACPFeedProduct(product, context.feed);
|
|
20
|
+
if (acpProduct) {
|
|
21
|
+
collected.push(acpProduct);
|
|
22
|
+
}
|
|
25
23
|
}
|
|
26
|
-
yield JSON.stringify({
|
|
27
|
-
...getTargetCountry(context.feed.languageContext) ? { target_country: getTargetCountry(context.feed.languageContext) } : {},
|
|
28
|
-
products: collected
|
|
29
|
-
});
|
|
24
|
+
yield JSON.stringify({ products: collected });
|
|
30
25
|
return;
|
|
31
26
|
}
|
|
32
27
|
for await (const product of products) {
|
|
33
|
-
|
|
34
|
-
|
|
28
|
+
const acpProduct = toACPFeedProduct(product, context.feed);
|
|
29
|
+
if (acpProduct) {
|
|
30
|
+
yield `${JSON.stringify(acpProduct)}
|
|
35
31
|
`;
|
|
36
32
|
}
|
|
37
33
|
}
|
|
38
34
|
}
|
|
39
35
|
};
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
36
|
+
const acpFeedMetadataTransformer = {
|
|
37
|
+
id: "acp-feed-metadata",
|
|
38
|
+
title: "ACP Feed Metadata",
|
|
39
|
+
description: "Agentic Commerce Protocol Feed API metadata.json (2026-04-17).",
|
|
40
|
+
output: {
|
|
41
|
+
contentType: "application/json; charset=utf-8",
|
|
42
|
+
fileExtension: "json"
|
|
43
|
+
},
|
|
44
|
+
async *transform(_products, context) {
|
|
45
|
+
yield JSON.stringify(toACPFeedMetadata(context.feedId, context.feed));
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
function toACPFeedMetadata(feedId, feed, updatedAt = /* @__PURE__ */ new Date()) {
|
|
49
|
+
const targetCountry = getTargetCountry(feed.languageContext);
|
|
51
50
|
return {
|
|
52
|
-
|
|
53
|
-
...
|
|
54
|
-
|
|
55
|
-
description: variant.description ?? productDescription(product),
|
|
56
|
-
...variant.url ?? product.url ? { url: variant.url ?? product.url } : {},
|
|
57
|
-
...product.brand ? { brand: product.brand } : {},
|
|
58
|
-
is_eligible_search: true,
|
|
59
|
-
...feed.sellerName ? { seller_name: feed.sellerName } : {},
|
|
60
|
-
...product.manufacturer ? { manufacturer: product.manufacturer } : {},
|
|
61
|
-
...product.categoryPath?.length ? {
|
|
62
|
-
product_category: product.categoryPath.map((category) => category.name).join(" > ")
|
|
63
|
-
} : {},
|
|
64
|
-
...product.ratingSummary ? {
|
|
65
|
-
...product.ratingSummary.totalRatings !== void 0 ? { review_count: product.ratingSummary.totalRatings } : {},
|
|
66
|
-
...product.ratingSummary.totalRatings ? { star_rating: product.ratingSummary.averageRating.toFixed(2) } : {}
|
|
67
|
-
} : {},
|
|
68
|
-
...primaryImageUrl ? { image_url: primaryImageUrl } : {},
|
|
69
|
-
...additionalImageUrls.length > 0 ? { additional_image_urls: additionalImageUrls } : {},
|
|
70
|
-
availability: toACPAvailability(variant.availability?.status),
|
|
71
|
-
...priceFields,
|
|
72
|
-
...gtin ? { gtin } : {},
|
|
73
|
-
...variant.manufacturerPartNumber ? { mpn: variant.manufacturerPartNumber } : {}
|
|
51
|
+
id: feedId,
|
|
52
|
+
...targetCountry ? { target_country: targetCountry } : {},
|
|
53
|
+
updated_at: updatedAt.toISOString()
|
|
74
54
|
};
|
|
75
55
|
}
|
|
76
|
-
function
|
|
77
|
-
if (product.variants.length
|
|
78
|
-
return
|
|
56
|
+
function toACPFeedProduct(product, feed) {
|
|
57
|
+
if (product.variants.length === 0) {
|
|
58
|
+
return void 0;
|
|
79
59
|
}
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
);
|
|
60
|
+
const media = toMedia(product.images);
|
|
61
|
+
const categoryPath = product.categoryPath?.map((category) => category.name).join(" > ");
|
|
83
62
|
return {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
63
|
+
id: product.id,
|
|
64
|
+
title: product.title,
|
|
65
|
+
...product.description ? { description: { plain: product.description } } : {},
|
|
66
|
+
...product.url ? { url: product.url } : {},
|
|
67
|
+
...media.length > 0 ? { media } : {},
|
|
68
|
+
variants: product.variants.map((variant) => toACPFeedVariant(variant, categoryPath, feed.sellerName))
|
|
87
69
|
};
|
|
88
70
|
}
|
|
89
|
-
function
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
sale_price: formatMoney(variant.price)
|
|
94
|
-
};
|
|
95
|
-
}
|
|
71
|
+
function toACPFeedVariant(variant, categoryPath, sellerName) {
|
|
72
|
+
const media = toMedia(variant.images);
|
|
73
|
+
const price = toFeedPrice(variant.price ?? variant.listPrice);
|
|
74
|
+
const listPrice = variant.price ? toFeedPrice(variant.listPrice) : void 0;
|
|
96
75
|
return {
|
|
97
|
-
|
|
76
|
+
id: variant.id,
|
|
77
|
+
title: variant.title,
|
|
78
|
+
...variant.description ? { description: { plain: variant.description } } : {},
|
|
79
|
+
...variant.url ? { url: variant.url } : {},
|
|
80
|
+
...variant.barcodes.length > 0 ? { barcodes: variant.barcodes.map((barcode) => ({ type: barcode.type, value: barcode.value })) } : {},
|
|
81
|
+
...price ? { price } : {},
|
|
82
|
+
// The regular price, reported when the selling price is below it.
|
|
83
|
+
...listPrice && price && listPrice.currency === price.currency && listPrice.amount > price.amount ? { list_price: listPrice } : {},
|
|
84
|
+
...variant.availability ? { availability: { available: variant.availability.available, status: variant.availability.status } } : {},
|
|
85
|
+
...categoryPath ? { categories: [{ value: categoryPath }] } : {},
|
|
86
|
+
...variant.options.length > 0 ? { variant_options: variant.options.map((option) => ({ name: option.name, value: option.value })) } : {},
|
|
87
|
+
...media.length > 0 ? { media } : {},
|
|
88
|
+
...sellerName ? { seller: { name: sellerName } } : {}
|
|
98
89
|
};
|
|
99
90
|
}
|
|
100
|
-
function
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
case "preorder":
|
|
107
|
-
return "pre_order";
|
|
108
|
-
default:
|
|
109
|
-
return "unknown";
|
|
110
|
-
}
|
|
91
|
+
function toMedia(images) {
|
|
92
|
+
return images.filter((image) => image.url.length > 0).map((image) => ({
|
|
93
|
+
type: "image",
|
|
94
|
+
url: image.url,
|
|
95
|
+
...image.altText ? { alt_text: image.altText } : {}
|
|
96
|
+
}));
|
|
111
97
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
98
|
+
const CURRENCY_EXPONENTS = {
|
|
99
|
+
BIF: 0,
|
|
100
|
+
CLP: 0,
|
|
101
|
+
DJF: 0,
|
|
102
|
+
GNF: 0,
|
|
103
|
+
ISK: 0,
|
|
104
|
+
JPY: 0,
|
|
105
|
+
KMF: 0,
|
|
106
|
+
KRW: 0,
|
|
107
|
+
PYG: 0,
|
|
108
|
+
RWF: 0,
|
|
109
|
+
UGX: 0,
|
|
110
|
+
UYI: 0,
|
|
111
|
+
VND: 0,
|
|
112
|
+
VUV: 0,
|
|
113
|
+
XAF: 0,
|
|
114
|
+
XOF: 0,
|
|
115
|
+
XPF: 0,
|
|
116
|
+
BHD: 3,
|
|
117
|
+
IQD: 3,
|
|
118
|
+
JOD: 3,
|
|
119
|
+
KWD: 3,
|
|
120
|
+
LYD: 3,
|
|
121
|
+
OMR: 3,
|
|
122
|
+
TND: 3
|
|
123
|
+
};
|
|
124
|
+
function toFeedPrice(money) {
|
|
125
|
+
if (!money) {
|
|
126
|
+
return void 0;
|
|
127
|
+
}
|
|
128
|
+
const currency = money.currency.toUpperCase();
|
|
129
|
+
const exponent = CURRENCY_EXPONENTS[currency] ?? 2;
|
|
130
|
+
return {
|
|
131
|
+
amount: Math.max(Math.round(money.value * 10 ** exponent), 0),
|
|
132
|
+
currency
|
|
133
|
+
};
|
|
125
134
|
}
|
|
126
135
|
export {
|
|
127
|
-
|
|
136
|
+
acpFeedMetadataTransformer,
|
|
137
|
+
acpProductFeedTransformer,
|
|
138
|
+
toACPFeedMetadata,
|
|
139
|
+
toACPFeedProduct
|
|
128
140
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reactionary/feeds",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.43",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"types": "./src/index.d.ts",
|
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
"url": "git+https://github.com/Solteq/reactionary.git"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@reactionary/algolia": "0.10.
|
|
16
|
-
"@reactionary/commercetools": "0.10.
|
|
17
|
-
"@reactionary/core": "0.10.
|
|
18
|
-
"@reactionary/fake": "0.10.
|
|
19
|
-
"@reactionary/magento": "0.10.
|
|
20
|
-
"@reactionary/medusa": "0.10.
|
|
21
|
-
"@reactionary/meilisearch": "0.10.
|
|
22
|
-
"@reactionary/unomi": "0.10.
|
|
15
|
+
"@reactionary/algolia": "0.10.43",
|
|
16
|
+
"@reactionary/commercetools": "0.10.43",
|
|
17
|
+
"@reactionary/core": "0.10.43",
|
|
18
|
+
"@reactionary/fake": "0.10.43",
|
|
19
|
+
"@reactionary/magento": "0.10.43",
|
|
20
|
+
"@reactionary/medusa": "0.10.43",
|
|
21
|
+
"@reactionary/meilisearch": "0.10.43",
|
|
22
|
+
"@reactionary/unomi": "0.10.43",
|
|
23
23
|
"commander": "^15.0.0",
|
|
24
24
|
"dotenv": "^17.2.2",
|
|
25
25
|
"sitemap": "^9.0.1",
|
package/src/index.d.ts
CHANGED
|
@@ -11,3 +11,4 @@ export * from './lib/transformers/acp-product-feed.transformer.js';
|
|
|
11
11
|
export * from './lib/transformers/google-merchant-feed.transformer.js';
|
|
12
12
|
export * from './lib/transformers/pricerunner-feed.transformer.js';
|
|
13
13
|
export * from './lib/transformers/sitemap-feed.transformer.js';
|
|
14
|
+
export * from './lib/acp-feed-publisher.js';
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ReactionaryFeedClient, ReactionaryFeedClientFactory, ReactionaryFeedDefinition, ReactionaryFeedInventoryOptions, ReactionaryFeedProcessingOptions, ReactionaryFeedProgress } from './feed-types.js';
|
|
2
|
+
import { type ACPFeedMetadata } from './transformers/acp-product-feed.transformer.js';
|
|
3
|
+
/** The environment variable holding the Feed API key, unless configured otherwise. */
|
|
4
|
+
export declare const DEFAULT_ACP_FEED_API_KEY_ENV = "ACP_FEED_API_KEY";
|
|
5
|
+
export interface ReactionaryACPFeedPublisherOptions extends ReactionaryFeedInventoryOptions, ReactionaryFeedProcessingOptions {
|
|
6
|
+
/** Base URL of the agent's Feed API, e.g. `https://agent.example/api`. */
|
|
7
|
+
feedApiBaseUrl: string;
|
|
8
|
+
/** The bearer token the agent issued to this merchant. */
|
|
9
|
+
apiKey: string;
|
|
10
|
+
/** Feed definitions by key: what to search, in which language context. */
|
|
11
|
+
feeds: Record<string, ReactionaryFeedDefinition>;
|
|
12
|
+
/** Products per `PATCH /feeds/{id}/products` request. Defaults to 100. */
|
|
13
|
+
batchSize?: number;
|
|
14
|
+
/** Fetch implementation; defaults to the global fetch. */
|
|
15
|
+
fetch?: typeof fetch;
|
|
16
|
+
/** Generation progress, e.g. for a CLI progress line. */
|
|
17
|
+
onProgress?: (progress: ReactionaryFeedProgress) => void;
|
|
18
|
+
}
|
|
19
|
+
export interface ACPFeedPublishResult {
|
|
20
|
+
feedId: string;
|
|
21
|
+
products: number;
|
|
22
|
+
batches: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Publishes product feeds to an agent's Feed API (ACP 2026-04-17). Feeds are
|
|
26
|
+
* agent-hosted and pushed by merchants: create the feed once, then upsert
|
|
27
|
+
* its products. For file ingestion instead, generate `products.jsonl` and
|
|
28
|
+
* `metadata.json` with the `@reactionary/feeds` CLI (`acp-product-feed`,
|
|
29
|
+
* `acp-feed-metadata`).
|
|
30
|
+
*/
|
|
31
|
+
export declare class ReactionaryACPFeedPublisher<TClient extends ReactionaryFeedClient = ReactionaryFeedClient> {
|
|
32
|
+
private readonly clientFactory;
|
|
33
|
+
private readonly options;
|
|
34
|
+
private readonly fetchImpl;
|
|
35
|
+
constructor(clientFactory: ReactionaryFeedClientFactory<TClient>, options: ReactionaryACPFeedPublisherOptions);
|
|
36
|
+
/** `POST /feeds`: creates a feed on the agent for the definition's market. */
|
|
37
|
+
createFeed(feedKey: string): Promise<ACPFeedMetadata>;
|
|
38
|
+
/** `GET /feeds/{id}`: the agent's metadata for a feed. */
|
|
39
|
+
getFeedMetadata(feedId: string): Promise<ACPFeedMetadata>;
|
|
40
|
+
/**
|
|
41
|
+
* Generates the feed's products and upserts them, in batches, into the
|
|
42
|
+
* agent-hosted feed with `PATCH /feeds/{feedId}/products`. The feed id
|
|
43
|
+
* defaults to the definition's `acp.feedId`, as handed out by the agent's
|
|
44
|
+
* onboarding. Upserts never remove products; to drop products, replace
|
|
45
|
+
* the feed by file ingestion.
|
|
46
|
+
*/
|
|
47
|
+
publish(feedKey: string, feedId?: string): Promise<ACPFeedPublishResult>;
|
|
48
|
+
private getFeed;
|
|
49
|
+
private request;
|
|
50
|
+
}
|
|
51
|
+
/** Where the agent's Feed API is, as handed out by the agent's onboarding. */
|
|
52
|
+
export interface ACPFeedApiConfig {
|
|
53
|
+
/** Base URL of the agent-hosted Feed API. */
|
|
54
|
+
baseUrl: string;
|
|
55
|
+
/** Environment variable holding the API key; defaults to `ACP_FEED_API_KEY`. */
|
|
56
|
+
apiKeyEnv?: string;
|
|
57
|
+
}
|
|
58
|
+
export interface ACPFeedPublishingConfig extends ReactionaryFeedInventoryOptions, ReactionaryFeedProcessingOptions {
|
|
59
|
+
acpFeedApi?: ACPFeedApiConfig;
|
|
60
|
+
feeds: Record<string, ReactionaryFeedDefinition>;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A publisher from a feeds config module, reading the API key from the
|
|
64
|
+
* environment so it never lives in configuration. `testMode` limits each
|
|
65
|
+
* feed to two search pages.
|
|
66
|
+
*/
|
|
67
|
+
export declare function createACPFeedPublisherFromConfig<TClient extends ReactionaryFeedClient>(config: ACPFeedPublishingConfig, clientFactory: ReactionaryFeedClientFactory<TClient>, options?: {
|
|
68
|
+
env?: Record<string, string | undefined>;
|
|
69
|
+
batchSize?: number;
|
|
70
|
+
testMode?: boolean;
|
|
71
|
+
fetch?: typeof fetch;
|
|
72
|
+
onProgress?: (progress: ReactionaryFeedProgress) => void;
|
|
73
|
+
}): ReactionaryACPFeedPublisher<TClient>;
|
package/src/lib/feed-types.d.ts
CHANGED
|
@@ -61,6 +61,11 @@ export interface ReactionaryFeedDefinition {
|
|
|
61
61
|
productUrlBase?: string;
|
|
62
62
|
sellerName?: string;
|
|
63
63
|
metadata?: Record<string, unknown>;
|
|
64
|
+
/** ACP: the agent-hosted feed this definition is published to. */
|
|
65
|
+
acp?: {
|
|
66
|
+
/** Feed id from the agent's onboarding (or from `create-acp-feed`). */
|
|
67
|
+
feedId: string;
|
|
68
|
+
};
|
|
64
69
|
}
|
|
65
70
|
export type ReactionarySitemapChangeFrequency = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
|
66
71
|
export interface ReactionarySitemapEntry {
|
|
@@ -1,4 +1,81 @@
|
|
|
1
1
|
import type { ReactionaryFeedTransformer } from '../feed-transformer.js';
|
|
2
|
+
import type { ReactionaryFeedDefinition, ReactionaryFeedProduct } from '../feed-types.js';
|
|
3
|
+
/** ACP Feed API `Price`: minor units and an upper-case ISO 4217 code. */
|
|
4
|
+
export interface ACPFeedPrice {
|
|
5
|
+
amount: number;
|
|
6
|
+
currency: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ACPFeedDescription {
|
|
9
|
+
plain?: string;
|
|
10
|
+
html?: string;
|
|
11
|
+
markdown?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ACPFeedMedia {
|
|
14
|
+
type: string;
|
|
15
|
+
url: string;
|
|
16
|
+
alt_text?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ACPFeedAvailability {
|
|
19
|
+
available?: boolean;
|
|
20
|
+
status?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface ACPFeedVariant {
|
|
23
|
+
id: string;
|
|
24
|
+
title: string;
|
|
25
|
+
description?: ACPFeedDescription;
|
|
26
|
+
url?: string;
|
|
27
|
+
barcodes?: Array<{
|
|
28
|
+
type: string;
|
|
29
|
+
value: string;
|
|
30
|
+
}>;
|
|
31
|
+
price?: ACPFeedPrice;
|
|
32
|
+
list_price?: ACPFeedPrice;
|
|
33
|
+
availability?: ACPFeedAvailability;
|
|
34
|
+
categories?: Array<{
|
|
35
|
+
value: string;
|
|
36
|
+
taxonomy?: string;
|
|
37
|
+
}>;
|
|
38
|
+
variant_options?: Array<{
|
|
39
|
+
name: string;
|
|
40
|
+
value: string;
|
|
41
|
+
}>;
|
|
42
|
+
media?: ACPFeedMedia[];
|
|
43
|
+
seller?: {
|
|
44
|
+
name?: string;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** ACP Feed API `Product` (2026-04-17), one line of `products.jsonl`. */
|
|
48
|
+
export interface ACPFeedProduct {
|
|
49
|
+
id: string;
|
|
50
|
+
title?: string;
|
|
51
|
+
description?: ACPFeedDescription;
|
|
52
|
+
url?: string;
|
|
53
|
+
media?: ACPFeedMedia[];
|
|
54
|
+
variants: ACPFeedVariant[];
|
|
55
|
+
}
|
|
56
|
+
/** ACP Feed API `FeedMetadata`, the content of `metadata.json`. */
|
|
57
|
+
export interface ACPFeedMetadata {
|
|
58
|
+
id: string;
|
|
59
|
+
target_country?: string;
|
|
60
|
+
updated_at?: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The ACP product feed (Feed API 2026-04-17): `jsonl` writes
|
|
64
|
+
* `products.jsonl` for file ingestion, one `Product` per line; `json` writes
|
|
65
|
+
* `{ "products": [...] }`, the body of `PATCH /feeds/{id}/products`.
|
|
66
|
+
*/
|
|
2
67
|
export declare const acpProductFeedTransformer: ReactionaryFeedTransformer<{
|
|
3
68
|
format?: 'jsonl' | 'json';
|
|
4
69
|
}>;
|
|
70
|
+
/**
|
|
71
|
+
* The ACP feed's `metadata.json` for file ingestion, alongside the
|
|
72
|
+
* `acp-product-feed` products.jsonl. Products are not read.
|
|
73
|
+
*/
|
|
74
|
+
export declare const acpFeedMetadataTransformer: ReactionaryFeedTransformer;
|
|
75
|
+
/** The feed's `metadata.json`: its id, target country and generation time. */
|
|
76
|
+
export declare function toACPFeedMetadata(feedId: string, feed: ReactionaryFeedDefinition, updatedAt?: Date): ACPFeedMetadata;
|
|
77
|
+
/**
|
|
78
|
+
* A feed product as an ACP `Product`. Products need at least one variant,
|
|
79
|
+
* so a product without variants is left out (undefined).
|
|
80
|
+
*/
|
|
81
|
+
export declare function toACPFeedProduct(product: ReactionaryFeedProduct, feed: Pick<ReactionaryFeedDefinition, 'sellerName'>): ACPFeedProduct | undefined;
|