From 4db48f4fde8d090a663f829da618239c7aa3a573 Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Wed, 3 Dec 2025 00:39:03 +1100 Subject: [PATCH 1/4] feat: add Brandfetch plugin --- plugins/brandfetch/credentials.ts | 3 + plugins/brandfetch/icon.tsx | 14 ++ plugins/brandfetch/index.ts | 81 ++++++++++ plugins/brandfetch/steps/get-brand.ts | 212 ++++++++++++++++++++++++++ plugins/brandfetch/test.ts | 42 +++++ 5 files changed, 352 insertions(+) create mode 100644 plugins/brandfetch/credentials.ts create mode 100644 plugins/brandfetch/icon.tsx create mode 100644 plugins/brandfetch/index.ts create mode 100644 plugins/brandfetch/steps/get-brand.ts create mode 100644 plugins/brandfetch/test.ts diff --git a/plugins/brandfetch/credentials.ts b/plugins/brandfetch/credentials.ts new file mode 100644 index 00000000..8c0082fb --- /dev/null +++ b/plugins/brandfetch/credentials.ts @@ -0,0 +1,3 @@ +export type BrandfetchCredentials = { + BRANDFETCH_API_KEY?: string; +}; diff --git a/plugins/brandfetch/icon.tsx b/plugins/brandfetch/icon.tsx new file mode 100644 index 00000000..68455560 --- /dev/null +++ b/plugins/brandfetch/icon.tsx @@ -0,0 +1,14 @@ +export function BrandfetchIcon({ className }: { className?: string }) { + return ( + + Brandfetch + + + ); +} diff --git a/plugins/brandfetch/index.ts b/plugins/brandfetch/index.ts new file mode 100644 index 00000000..d95a2f67 --- /dev/null +++ b/plugins/brandfetch/index.ts @@ -0,0 +1,81 @@ +import type { IntegrationPlugin } from "../registry"; +import { registerIntegration } from "../registry"; +import { BrandfetchIcon } from "./icon"; + +const brandfetchPlugin: IntegrationPlugin = { + type: "brandfetch", + label: "Brandfetch", + description: "Get brand assets and company data", + + icon: BrandfetchIcon, + + formFields: [ + { + id: "apiKey", + label: "API Key", + type: "password", + placeholder: "your-api-key", + configKey: "apiKey", + envVar: "BRANDFETCH_API_KEY", + helpText: "Get your API key from ", + helpLink: { + text: "Brandfetch Dashboard", + url: "https://developers.brandfetch.com/dashboard/keys", + }, + }, + ], + + testConfig: { + getTestFunction: async () => { + const { testBrandfetch } = await import("./test"); + return testBrandfetch; + }, + }, + + actions: [ + { + slug: "get-brand", + label: "Get Brand", + description: "Get a company's brand assets by domain, ticker, or ISIN", + category: "Brandfetch", + stepFunction: "getBrandStep", + stepImportPath: "get-brand", + outputFields: [ + { field: "name", description: "Brand name" }, + { field: "domain", description: "Brand domain" }, + { field: "description", description: "Brand description" }, + { field: "logoUrl", description: "Primary logo URL" }, + { field: "iconUrl", description: "Icon/symbol URL" }, + { field: "colors", description: "Array of brand colors (hex)" }, + { field: "links", description: "Social media and website links" }, + { field: "industry", description: "Primary industry" }, + ], + configFields: [ + { + key: "identifierType", + label: "Identifier Type", + type: "select", + options: [ + { value: "domain", label: "Domain" }, + { value: "ticker", label: "Stock Ticker" }, + { value: "isin", label: "ISIN" }, + ], + defaultValue: "domain", + required: true, + }, + { + key: "identifier", + label: "Identifier", + type: "template-input", + placeholder: "nike.com / NKE / US6541061031", + example: "nike.com", + required: true, + }, + ], + }, + ], +}; + +registerIntegration(brandfetchPlugin); + +export default brandfetchPlugin; diff --git a/plugins/brandfetch/steps/get-brand.ts b/plugins/brandfetch/steps/get-brand.ts new file mode 100644 index 00000000..87a87113 --- /dev/null +++ b/plugins/brandfetch/steps/get-brand.ts @@ -0,0 +1,212 @@ +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import { getErrorMessage } from "@/lib/utils"; +import type { BrandfetchCredentials } from "../credentials"; + +const BRANDFETCH_API_URL = "https://api.brandfetch.io/v2"; + +type BrandfetchLogo = { + type: string; + theme: string; + formats: Array<{ + src: string; + format: string; + }>; +}; + +type BrandfetchColor = { + hex: string; + type: string; + brightness: number; +}; + +type BrandfetchLink = { + name: string; + url: string; +}; + +type BrandfetchIndustry = { + name: string; + slug: string; + score: number; +}; + +type BrandfetchResponse = { + id: string; + name: string; + domain: string; + description?: string; + longDescription?: string; + logos?: BrandfetchLogo[]; + colors?: BrandfetchColor[]; + links?: BrandfetchLink[]; + company?: { + industries?: BrandfetchIndustry[]; + }; +}; + +type GetBrandResult = + | { + success: true; + name: string; + domain: string; + description: string; + logoUrl: string; + iconUrl: string; + colors: string[]; + links: Array<{ name: string; url: string }>; + industry: string; + } + | { success: false; error: string }; + +export type GetBrandCoreInput = { + identifierType: "domain" | "ticker" | "isin"; + identifier: string; +}; + +export type GetBrandInput = StepInput & + GetBrandCoreInput & { + integrationId?: string; + }; + +function findLogoUrl(logos: BrandfetchLogo[] | undefined, type: string): string { + if (!logos) return ""; + + const logo = logos.find((l) => l.type === type); + if (!logo || !logo.formats || logo.formats.length === 0) return ""; + + // Prefer PNG or SVG + const pngFormat = logo.formats.find((f) => f.format === "png"); + if (pngFormat) return pngFormat.src; + + const svgFormat = logo.formats.find((f) => f.format === "svg"); + if (svgFormat) return svgFormat.src; + + return logo.formats[0].src; +} + +async function stepHandler( + input: GetBrandCoreInput, + credentials: BrandfetchCredentials +): Promise { + const apiKey = credentials.BRANDFETCH_API_KEY; + + if (!apiKey) { + return { + success: false, + error: + "BRANDFETCH_API_KEY is not configured. Please add it in Project Integrations.", + }; + } + + if (!input.identifier) { + return { + success: false, + error: "Identifier is required", + }; + } + + // Validate identifier format based on type + const identifier = input.identifier.trim(); + const identifierType = input.identifierType || "domain"; + + if (identifierType === "domain") { + if (!identifier.includes(".")) { + return { + success: false, + error: "Invalid domain format. Expected format: example.com", + }; + } + } else if (identifierType === "ticker") { + if (!/^[A-Z]{1,5}$/.test(identifier.toUpperCase())) { + return { + success: false, + error: "Invalid ticker format. Expected 1-5 uppercase letters (e.g., NKE)", + }; + } + } else if (identifierType === "isin") { + if (!/^[A-Z]{2}[A-Z0-9]{10}$/.test(identifier.toUpperCase())) { + return { + success: false, + error: "Invalid ISIN format. Expected 12 characters starting with country code (e.g., US6541061031)", + }; + } + } + + try { + const response = await fetch( + `${BRANDFETCH_API_URL}/brands/${encodeURIComponent(identifier)}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + }, + } + ); + + if (!response.ok) { + if (response.status === 404) { + return { + success: false, + error: `Brand not found for: ${input.identifier}`, + }; + } + const errorData = (await response.json().catch(() => ({}))) as { + message?: string; + }; + return { + success: false, + error: errorData.message || `HTTP ${response.status}`, + }; + } + + const brand = (await response.json()) as BrandfetchResponse; + + // Extract primary logo and icon URLs + const logoUrl = findLogoUrl(brand.logos, "logo"); + const iconUrl = findLogoUrl(brand.logos, "icon") || findLogoUrl(brand.logos, "symbol"); + + // Extract colors (just hex values) + const colors = brand.colors?.map((c) => c.hex) || []; + + // Extract links + const links = brand.links?.map((l) => ({ name: l.name, url: l.url })) || []; + + // Get primary industry + const industry = brand.company?.industries?.[0]?.name || ""; + + return { + success: true, + name: brand.name || "", + domain: brand.domain || "", + description: brand.description || brand.longDescription || "", + logoUrl, + iconUrl, + colors, + links, + industry, + }; + } catch (error) { + return { + success: false, + error: `Failed to get brand: ${getErrorMessage(error)}`, + }; + } +} + +export async function getBrandStep( + input: GetBrandInput +): Promise { + "use step"; + + const credentials = input.integrationId + ? await fetchCredentials(input.integrationId) + : {}; + + return withStepLogging(input, () => stepHandler(input, credentials)); +} +getBrandStep.maxRetries = 0; + +export const _integrationType = "brandfetch"; diff --git a/plugins/brandfetch/test.ts b/plugins/brandfetch/test.ts new file mode 100644 index 00000000..78e75e49 --- /dev/null +++ b/plugins/brandfetch/test.ts @@ -0,0 +1,42 @@ +const BRANDFETCH_API_URL = "https://api.brandfetch.io/v2"; + +export async function testBrandfetch(credentials: Record) { + try { + const apiKey = credentials.BRANDFETCH_API_KEY; + + if (!apiKey) { + return { + success: false, + error: "BRANDFETCH_API_KEY is required", + }; + } + + // Use brandfetch.com domain for testing (free and doesn't count against quota) + const response = await fetch(`${BRANDFETCH_API_URL}/brands/brandfetch.com`, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + if (response.status === 401) { + return { + success: false, + error: "Invalid API key. Please check your Brandfetch API key.", + }; + } + return { + success: false, + error: `API validation failed: HTTP ${response.status}`, + }; + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} From d82911ed0bc70e2fdfe70c88116a626b6433b7df Mon Sep 17 00:00:00 2001 From: Ben Sabic Date: Fri, 5 Dec 2025 13:26:27 +1100 Subject: [PATCH 2/4] fix: add brandfetch plugin import and normalize ticker/ISIN to uppercase --- plugins/brandfetch/steps/get-brand.ts | 8 +++++--- plugins/index.ts | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/plugins/brandfetch/steps/get-brand.ts b/plugins/brandfetch/steps/get-brand.ts index 87a87113..dd8d3de7 100644 --- a/plugins/brandfetch/steps/get-brand.ts +++ b/plugins/brandfetch/steps/get-brand.ts @@ -109,7 +109,7 @@ async function stepHandler( } // Validate identifier format based on type - const identifier = input.identifier.trim(); + let identifier = input.identifier.trim(); const identifierType = input.identifierType || "domain"; if (identifierType === "domain") { @@ -120,14 +120,16 @@ async function stepHandler( }; } } else if (identifierType === "ticker") { - if (!/^[A-Z]{1,5}$/.test(identifier.toUpperCase())) { + identifier = identifier.toUpperCase(); + if (!/^[A-Z]{1,5}$/.test(identifier)) { return { success: false, error: "Invalid ticker format. Expected 1-5 uppercase letters (e.g., NKE)", }; } } else if (identifierType === "isin") { - if (!/^[A-Z]{2}[A-Z0-9]{10}$/.test(identifier.toUpperCase())) { + identifier = identifier.toUpperCase(); + if (!/^[A-Z]{2}[A-Z0-9]{10}$/.test(identifier)) { return { success: false, error: "Invalid ISIN format. Expected 12 characters starting with country code (e.g., US6541061031)", diff --git a/plugins/index.ts b/plugins/index.ts index c2b41249..98698aca 100644 --- a/plugins/index.ts +++ b/plugins/index.ts @@ -16,6 +16,7 @@ import "./ai-gateway"; import "./blob"; +import "./brandfetch"; import "./fal"; import "./firecrawl"; import "./github"; From f4fda822e7256f4cc42637cc5bc19ff620f0afba Mon Sep 17 00:00:00 2001 From: Ben Sabic <27636870+bensabic@users.noreply.github.com> Date: Sat, 6 Dec 2025 16:50:36 +1100 Subject: [PATCH 3/4] chore: update plugins/brandfetch/steps/get-brand.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- plugins/brandfetch/steps/get-brand.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/brandfetch/steps/get-brand.ts b/plugins/brandfetch/steps/get-brand.ts index dd8d3de7..53046510 100644 --- a/plugins/brandfetch/steps/get-brand.ts +++ b/plugins/brandfetch/steps/get-brand.ts @@ -152,7 +152,7 @@ async function stepHandler( if (response.status === 404) { return { success: false, - error: `Brand not found for: ${input.identifier}`, + error: `Brand not found for: ${identifier}`, }; } const errorData = (await response.json().catch(() => ({}))) as { From fb42f05fde811391e8c9379b5f0dfb645d78a155 Mon Sep 17 00:00:00 2001 From: Ben Sabic <27636870+bensabic@users.noreply.github.com> Date: Sat, 6 Dec 2025 16:50:54 +1100 Subject: [PATCH 4/4] chore: update plugins/brandfetch/steps/get-brand.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- plugins/brandfetch/steps/get-brand.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/brandfetch/steps/get-brand.ts b/plugins/brandfetch/steps/get-brand.ts index 53046510..3203d739 100644 --- a/plugins/brandfetch/steps/get-brand.ts +++ b/plugins/brandfetch/steps/get-brand.ts @@ -113,7 +113,10 @@ async function stepHandler( const identifierType = input.identifierType || "domain"; if (identifierType === "domain") { - if (!identifier.includes(".")) { + // Validate domain format: must have at least one character before and after the dot, no spaces, no leading/trailing dot + if ( + !/^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?(\.[a-zA-Z]{2,})+$/.test(identifier) + ) { return { success: false, error: "Invalid domain format. Expected format: example.com",