Automattic\WooCommerce\Internal\CLI\Migrator\Platforms\Webflow
WebflowFetcher{} │ WC 1.0└─ PlatformFetcherInterface
Fetches products (and ecommerce category metadata) from the Webflow v2 API.
The Webflow API exposes offset/limit pagination rather than cursors, but the migrator's PlatformFetcherInterface speaks cursors. We use the next offset as a stringified cursor and translate it back to an offset query parameter.
Webflow does not expose a /products/categories endpoint. Categories live in an auto-provisioned CMS collection (slug category). We resolve them once via /sites/{id}/collections + /collections/{id}/items and stash a map of cms_item_id => { name, slug } and decorates each fetched product item with a _resolved_categories property so the mapper can read categories inline.
No Hooks.
Usage
$WebflowFetcher = new WebflowFetcher(); // use class methods
Methods
- public __construct( array $credentials )
- public fetch_batch( array $args )
- public fetch_total_count( array $args )
- private decorate_items_with_categories( array &$items )
- private empty_batch()
- private ensure_category_cache_loaded( string $site_id )
- private extract_collections_list( $response )
- private find_category_collection_id( array $collections )
- private load_collection_items_map( string $collection_id )
WebflowFetcher{} WebflowFetcher{} code WC 11.0.0
class WebflowFetcher implements PlatformFetcherInterface {
/**
* Maximum page size allowed by Webflow.
*
* @var int
*/
private const MAX_PAGE_SIZE = 100;
/**
* The Webflow client instance.
*
* @var WebflowClient
*/
private WebflowClient $webflow_client;
/**
* Cached category map: cms_item_id => array{name:string, slug:string}.
*
* Null until resolved; empty array if the site has no categories collection.
*
* @var array<string,array{name:string,slug:string}>|null
*/
private ?array $category_cache = null;
/**
* Constructor.
*
* @param array $credentials Platform credentials array.
*/
public function __construct( array $credentials ) {
$this->webflow_client = new WebflowClient( $credentials );
}
/**
* Fetches a batch of products from the Webflow REST API.
*
* @param array $args Arguments for fetching. Supported keys:
* - 'limit': Max number of items per batch (default: 50, capped at 100).
* - 'after_cursor': Stringified next-offset for pagination (optional).
*
* @return array{items: array, cursor: ?string, has_next_page: bool}
*/
public function fetch_batch( array $args ): array {
$site_id = $this->webflow_client->get_site_id();
if ( is_wp_error( $site_id ) ) {
// @phpstan-ignore-next-line class.notFound
\WP_CLI::warning( 'Failed to fetch Webflow products: ' . $site_id->get_error_message() );
return $this->empty_batch();
}
$limit = (int) ( $args['limit'] ?? 50 );
$limit = max( 1, min( $limit, self::MAX_PAGE_SIZE ) );
$offset = 0;
if ( isset( $args['after_cursor'] ) && is_numeric( $args['after_cursor'] ) ) {
$offset = max( 0, (int) $args['after_cursor'] );
}
$query = array(
'limit' => $limit,
'offset' => $offset,
);
$response = $this->webflow_client->rest_request( "/sites/{$site_id}/products", $query );
if ( is_wp_error( $response ) ) {
// @phpstan-ignore-next-line class.notFound
\WP_CLI::warning( 'Failed to fetch products from Webflow: ' . $response->get_error_message() );
return $this->empty_batch();
}
if ( ! is_object( $response ) || ! isset( $response->items ) || ! is_array( $response->items ) ) {
// @phpstan-ignore-next-line class.notFound
\WP_CLI::warning( 'Invalid Webflow response: missing items array.' );
return $this->empty_batch();
}
$items = $response->items;
$count = count( $items );
$total = isset( $response->pagination->total ) ? (int) $response->pagination->total : ( $offset + $count );
$next = $offset + $count;
$has_next = $count > 0 && $next < $total;
$this->ensure_category_cache_loaded( $site_id );
$this->decorate_items_with_categories( $items );
// The cursor is simply the next offset. Unlike Shopify's stable cursors, offset paging can
// skip or double-import items if the Webflow catalog changes (a product added or removed)
// between batches. Acceptable here given Webflow's API offers no stable cursor.
return array(
'items' => $items,
'cursor' => $has_next ? (string) $next : null,
'has_next_page' => $has_next,
);
}
/**
* Fetches the total count of products from Webflow.
*
* @param array $args Filter arguments (unused; Webflow's products endpoint does not accept filters).
* @return int Total product count, or 0 on failure.
*/
public function fetch_total_count( array $args ): int {
unset( $args );
// Webflow does not support filtered count queries here.
$site_id = $this->webflow_client->get_site_id();
if ( is_wp_error( $site_id ) ) {
// @phpstan-ignore-next-line class.notFound
\WP_CLI::warning( 'Could not fetch Webflow product count: ' . $site_id->get_error_message() );
return 0;
}
$response = $this->webflow_client->rest_request(
"/sites/{$site_id}/products",
array(
'limit' => 1,
'offset' => 0,
)
);
if ( is_wp_error( $response ) ) {
// @phpstan-ignore-next-line class.notFound
\WP_CLI::warning( 'Could not fetch Webflow product count: ' . $response->get_error_message() );
return 0;
}
if ( ! is_object( $response ) || ! isset( $response->pagination->total ) ) {
// @phpstan-ignore-next-line class.notFound
\WP_CLI::warning( 'Unexpected response from Webflow products endpoint - missing pagination.total.' );
return 0;
}
return (int) $response->pagination->total;
}
/**
* Decorate each product item with a `_resolved_categories` property whose value is an
* array of `{name, slug}` for every recognized CMS item id in `product.fieldData.category`.
*
* This is how the mapper learns about category names without needing access to the
* fetcher or making its own API calls. Missing/archived ids are silently dropped.
*
* @param array<int,object> $items Items array (mutated in place).
* @return void
*/
private function decorate_items_with_categories( array &$items ): void {
if ( null === $this->category_cache || empty( $this->category_cache ) ) {
foreach ( $items as $item ) {
if ( is_object( $item ) ) {
// @phpstan-ignore-next-line property.notFound
$item->_resolved_categories = array();
}
}
return;
}
foreach ( $items as $item ) {
if ( ! is_object( $item ) ) {
continue;
}
$category_ids = array();
if ( isset( $item->product->fieldData->category ) && is_array( $item->product->fieldData->category ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
$category_ids = $item->product->fieldData->category; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
}
$resolved = array();
foreach ( $category_ids as $cms_item_id ) {
$cms_item_id = (string) $cms_item_id;
if ( isset( $this->category_cache[ $cms_item_id ] ) ) {
$resolved[] = $this->category_cache[ $cms_item_id ];
}
}
// @phpstan-ignore-next-line property.notFound
$item->_resolved_categories = $resolved;
}
}
/**
* Ensures the category cache has been resolved at most once per fetcher instance.
*
* @param string $site_id Webflow site ID.
* @return void
*/
private function ensure_category_cache_loaded( string $site_id ): void {
if ( null !== $this->category_cache ) {
return;
}
$this->category_cache = array();
$collections_response = $this->webflow_client->rest_request( "/sites/{$site_id}/collections" );
if ( is_wp_error( $collections_response ) ) {
// @phpstan-ignore-next-line class.notFound
\WP_CLI::debug( 'Could not load Webflow collections list (categories will not be resolved): ' . $collections_response->get_error_message() );
return;
}
$collections = $this->extract_collections_list( $collections_response );
$category_id = $this->find_category_collection_id( $collections );
if ( null === $category_id ) {
// @phpstan-ignore-next-line class.notFound
\WP_CLI::debug( 'No "category" collection found on Webflow site; product categories will be skipped.' );
return;
}
$this->category_cache = $this->load_collection_items_map( $category_id );
}
/**
* Webflow's /collections endpoint sometimes returns a bare array and sometimes an envelope.
* Normalize to a flat list of collection objects.
*
* @param mixed $response The raw decoded API response.
* @return array<int,object>
*/
private function extract_collections_list( $response ): array {
if ( is_array( $response ) ) {
return $response;
}
if ( is_object( $response ) && isset( $response->collections ) && is_array( $response->collections ) ) {
return $response->collections;
}
return array();
}
/**
* Find the auto-provisioned Ecommerce Categories collection.
*
* @param array<int,object> $collections List of collection objects.
* @return string|null Collection ID or null if not found.
*/
private function find_category_collection_id( array $collections ): ?string {
foreach ( $collections as $collection ) {
if ( ! is_object( $collection ) || empty( $collection->id ) ) {
continue;
}
$slug = isset( $collection->slug ) ? (string) $collection->slug : '';
$display_name = isset( $collection->displayName ) ? (string) $collection->displayName : ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
if ( 'category' === $slug || 'Categories' === $display_name ) {
return (string) $collection->id;
}
}
return null;
}
/**
* Page through a CMS collection and build an id => {name, slug} map.
*
* @param string $collection_id The Webflow CMS collection ID.
* @return array<string,array{name:string,slug:string}>
*/
private function load_collection_items_map( string $collection_id ): array {
$map = array();
$offset = 0;
$page_size = self::MAX_PAGE_SIZE;
$max_pages = 100;
// Safety net to avoid runaway loops on broken pagination.
$page_index = 0;
while ( $page_index < $max_pages ) {
$response = $this->webflow_client->rest_request(
"/collections/{$collection_id}/items",
array(
'limit' => $page_size,
'offset' => $offset,
)
);
if ( is_wp_error( $response ) ) {
// @phpstan-ignore-next-line class.notFound
\WP_CLI::debug( 'Could not load Webflow category items: ' . $response->get_error_message() );
break;
}
$items = ( is_object( $response ) && isset( $response->items ) && is_array( $response->items ) ) ? $response->items : array();
if ( empty( $items ) ) {
break;
}
foreach ( $items as $item ) {
if ( ! is_object( $item ) || empty( $item->id ) ) {
continue;
}
$field_data = isset( $item->fieldData ) && is_object( $item->fieldData ) ? $item->fieldData : null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Webflow API uses camelCase.
if ( null === $field_data ) {
continue;
}
$name = isset( $field_data->name ) ? (string) $field_data->name : '';
$slug = isset( $field_data->slug ) ? (string) $field_data->slug : sanitize_title( $name );
if ( '' === $name ) {
continue;
}
$map[ (string) $item->id ] = array(
'name' => $name,
'slug' => $slug,
);
}
$total = isset( $response->pagination->total ) ? (int) $response->pagination->total : ( $offset + count( $items ) );
$offset += count( $items );
if ( $offset >= $total ) {
break;
}
++$page_index;
}
return $map;
}
/**
* Empty-batch response helper.
*
* @return array{items: array, cursor: null, has_next_page: false}
*/
private function empty_batch(): array {
return array(
'items' => array(),
'cursor' => null,
'has_next_page' => false,
);
}
}