Files
simstudioai--sim/apps/sim/connectors/webflow/webflow.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

457 lines
14 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { htmlToPlainText, parseMultiValue, parseTagDate } from '@/connectors/utils'
import { webflowConnectorMeta } from '@/connectors/webflow/meta'
const logger = createLogger('WebflowConnector')
const WEBFLOW_API = 'https://api.webflow.com/v2'
const PAGE_SIZE = 100
interface WebflowCollection {
id: string
displayName: string
slug: string
}
interface WebflowItem {
id: string
fieldData: Record<string, unknown>
lastPublished?: string
lastUpdated?: string
createdOn?: string
}
interface WebflowPagination {
total: number
offset: number
limit: number
}
interface CursorState {
collectionIndex: number
offset: number
collections: string[]
}
/**
* Formats a CMS item's field data into structured plain text.
*/
function itemToPlainText(item: WebflowItem, collectionName: string): string {
const lines: string[] = []
const fieldData = item.fieldData || {}
const title = (fieldData.name as string) || (fieldData.title as string) || 'Untitled'
lines.push(`# ${title}`)
lines.push(`Collection: ${collectionName}`)
for (const [key, value] of Object.entries(fieldData)) {
if (value == null) continue
if (key === 'name' || key === 'title') continue
if (Array.isArray(value)) {
const items = value.map((v) => {
if (typeof v === 'object' && v !== null) {
return JSON.stringify(v)
}
return String(v)
})
lines.push(`${key}: ${items.join(', ')}`)
} else if (typeof value === 'object') {
lines.push(`${key}: ${JSON.stringify(value)}`)
} else if (typeof value === 'string' && /<[a-z][^>]*>/i.test(value)) {
lines.push(`${key}: ${htmlToPlainText(value)}`)
} else {
lines.push(`${key}: ${String(value)}`)
}
}
return lines.join('\n')
}
/**
* Extracts a human-readable title from a Webflow CMS item.
*/
function extractItemTitle(item: WebflowItem): string {
const fieldData = item.fieldData || {}
return (fieldData.name as string) || (fieldData.title as string) || 'Untitled'
}
export const webflowConnector: ConnectorConfig = {
...webflowConnectorMeta,
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string,
syncContext?: Record<string, unknown>
): Promise<ExternalDocumentList> => {
const siteId = sourceConfig.siteId as string
const collectionIds = parseMultiValue(sourceConfig.collectionId)
const maxItems = sourceConfig.maxItems ? Number(sourceConfig.maxItems) : 0
let cursorState: CursorState
if (cursor) {
cursorState = JSON.parse(cursor) as CursorState
} else {
const collections = await fetchCollectionIds(accessToken, siteId, collectionIds)
cursorState = { collectionIndex: 0, offset: 0, collections }
}
if (cursorState.collections.length === 0) {
return { documents: [], hasMore: false }
}
if (syncContext && !syncContext.collectionNames) {
syncContext.collectionNames = {}
}
const totalDocsFetched = (syncContext?.totalDocsFetched as number) ?? 0
if (maxItems > 0 && totalDocsFetched >= maxItems) {
return { documents: [], hasMore: false }
}
const currentCollectionId = cursorState.collections[cursorState.collectionIndex]
const collectionName = await fetchCollectionName(accessToken, currentCollectionId, syncContext)
const params = new URLSearchParams()
params.append('limit', String(PAGE_SIZE))
params.append('offset', String(cursorState.offset))
const url = `${WEBFLOW_API}/collections/${currentCollectionId}/items?${params.toString()}`
logger.info('Listing Webflow CMS items', {
siteId,
collectionId: currentCollectionId,
offset: cursorState.offset,
})
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'accept-version': '2.0.0',
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Failed to list Webflow items', {
status: response.status,
error: errorText,
})
throw new Error(`Failed to list Webflow items: ${response.status}`)
}
const data = (await response.json()) as {
items: WebflowItem[]
pagination: WebflowPagination
}
const items = data.items || []
const pageDocuments: ExternalDocument[] = items.map((item) =>
itemToDocument(item, currentCollectionId, collectionName)
)
let documents = pageDocuments
if (maxItems > 0) {
const remaining = Math.max(0, maxItems - totalDocsFetched)
if (documents.length > remaining) {
documents = documents.slice(0, remaining)
}
}
if (syncContext) {
syncContext.totalDocsFetched = totalDocsFetched + documents.length
}
const { pagination } = data
const hasMoreInCollection = cursorState.offset + pagination.limit < pagination.total
const hasMoreCollections = cursorState.collectionIndex < cursorState.collections.length - 1
const hitMaxItems = maxItems > 0 && totalDocsFetched + documents.length >= maxItems
/**
* When the cap stops the sync, flag the listing as capped so the sync engine
* skips deletion reconciliation — otherwise still-existing documents that
* were never listed get hard-deleted. "More" means any of: items dropped
* from this page (`pageDocuments.length > documents.length`), more pages in
* this collection, or more collections still to visit. The within-page drop
* is the only signal when a collection fits in a single API response.
*/
const droppedWithinPage = documents.length < pageDocuments.length
if (
syncContext &&
hitMaxItems &&
(droppedWithinPage || hasMoreInCollection || hasMoreCollections)
) {
syncContext.listingCapped = true
}
let nextCursor: string | undefined
if (hitMaxItems) {
nextCursor = undefined
} else if (hasMoreInCollection) {
nextCursor = JSON.stringify({
collectionIndex: cursorState.collectionIndex,
offset: cursorState.offset + pagination.limit,
collections: cursorState.collections,
})
} else if (hasMoreCollections) {
nextCursor = JSON.stringify({
collectionIndex: cursorState.collectionIndex + 1,
offset: 0,
collections: cursorState.collections,
})
}
return {
documents,
nextCursor,
hasMore: Boolean(nextCursor),
}
},
getDocument: async (
accessToken: string,
_sourceConfig: Record<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
const separatorIndex = externalId.indexOf(':')
if (separatorIndex === -1) {
logger.error('Invalid externalId format, expected collectionId:itemId', { externalId })
return null
}
const docCollectionId = externalId.slice(0, separatorIndex)
const itemId = externalId.slice(separatorIndex + 1)
const url = `${WEBFLOW_API}/collections/${docCollectionId}/items/${itemId}`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'accept-version': '2.0.0',
},
})
if (!response.ok) {
if (response.status === 404) return null
throw new Error(`Failed to get Webflow item: ${response.status}`)
}
const item = (await response.json()) as WebflowItem
const collectionName = await fetchCollectionNameDirect(accessToken, docCollectionId)
return itemToDocument(item, docCollectionId, collectionName)
},
validateConfig: async (
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const siteId = sourceConfig.siteId as string
const collectionIds = parseMultiValue(sourceConfig.collectionId)
const maxItems = sourceConfig.maxItems as string | undefined
if (!siteId) {
return { valid: false, error: 'Site ID is required' }
}
if (maxItems && (Number.isNaN(Number(maxItems)) || Number(maxItems) <= 0)) {
return { valid: false, error: 'Max items must be a positive number' }
}
try {
const siteUrl = `${WEBFLOW_API}/sites/${siteId}`
const siteResponse = await fetchWithRetry(
siteUrl,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'accept-version': '2.0.0',
},
},
VALIDATE_RETRY_OPTIONS
)
if (!siteResponse.ok) {
if (siteResponse.status === 404) {
return { valid: false, error: `Site "${siteId}" not found` }
}
if (siteResponse.status === 403 || siteResponse.status === 401) {
return { valid: false, error: 'Access denied. Check your Webflow permissions.' }
}
const errorText = await siteResponse.text()
return { valid: false, error: `Webflow API error: ${siteResponse.status} - ${errorText}` }
}
for (const collectionId of collectionIds) {
const collectionUrl = `${WEBFLOW_API}/collections/${collectionId}`
const collectionResponse = await fetchWithRetry(
collectionUrl,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'accept-version': '2.0.0',
},
},
VALIDATE_RETRY_OPTIONS
)
if (!collectionResponse.ok) {
if (collectionResponse.status === 404) {
return { valid: false, error: `Collection "${collectionId}" not found` }
}
return {
valid: false,
error: `Failed to verify collection: ${collectionResponse.status}`,
}
}
}
return { valid: true }
} catch (error) {
const message = getErrorMessage(error, 'Failed to validate configuration')
return { valid: false, error: message }
}
},
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
if (typeof metadata.collectionName === 'string') {
result.collectionName = metadata.collectionName
}
const lastModified = parseTagDate(metadata.lastModified)
if (lastModified) result.lastModified = lastModified
if (typeof metadata.slug === 'string') {
result.slug = metadata.slug
}
return result
},
}
/**
* Converts a Webflow CMS item to an ExternalDocument.
*/
function itemToDocument(
item: WebflowItem,
collectionId: string,
collectionName: string
): ExternalDocument {
const plainText = itemToPlainText(item, collectionName)
const lastModified = item.lastUpdated || item.lastPublished || item.createdOn || ''
const contentHash = `webflow:${item.id}:${lastModified}`
const title = extractItemTitle(item)
const slug = (item.fieldData?.slug as string) || ''
return {
externalId: `${collectionId}:${item.id}`,
title,
content: plainText,
mimeType: 'text/plain',
contentHash,
metadata: {
collectionName,
lastModified: item.lastUpdated || item.lastPublished || item.createdOn,
slug,
},
}
}
/**
* Fetches collection IDs for a site. If a specific collectionId is provided,
* returns only that ID. Otherwise fetches all collections from the site.
*/
async function fetchCollectionIds(
accessToken: string,
siteId: string,
collectionIds: string[]
): Promise<string[]> {
if (collectionIds.length > 0) {
return collectionIds
}
const url = `${WEBFLOW_API}/sites/${siteId}/collections`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'accept-version': '2.0.0',
},
})
if (!response.ok) {
throw new Error(`Failed to list Webflow collections: ${response.status}`)
}
const data = (await response.json()) as { collections: WebflowCollection[] }
return (data.collections || []).map((c) => c.id)
}
/**
* Fetches a collection's display name, caching in syncContext.
*/
async function fetchCollectionName(
accessToken: string,
collectionId: string,
syncContext?: Record<string, unknown>
): Promise<string> {
const names = (syncContext?.collectionNames ?? {}) as Record<string, string>
if (names[collectionId]) return names[collectionId]
const name = await fetchCollectionNameDirect(accessToken, collectionId)
if (syncContext) {
const cached = (syncContext.collectionNames ?? {}) as Record<string, string>
cached[collectionId] = name
syncContext.collectionNames = cached
}
return name
}
/**
* Fetches a collection's display name directly from the API.
*/
async function fetchCollectionNameDirect(
accessToken: string,
collectionId: string
): Promise<string> {
try {
const url = `${WEBFLOW_API}/collections/${collectionId}`
const response = await fetchWithRetry(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
'accept-version': '2.0.0',
},
})
if (!response.ok) {
logger.warn('Failed to fetch collection name', {
collectionId,
status: response.status,
})
return collectionId
}
const data = (await response.json()) as WebflowCollection
return data.displayName || data.slug || collectionId
} catch (error) {
logger.warn('Error fetching collection name', {
collectionId,
error: toError(error).message,
})
return collectionId
}
}