Exempel: hämta media med TypeScript

Det här är ett komplett och körbart exempel som hämtar media från /v2.2/media
för en leverantör, sparar svaret som JSON och visar hur de enskilda fälten
används. Koden är avsedd att läsas och anpassas, inte att kopieras in i
produktion som den är. Den är samlad i en enda fil för att vara lätt att följa
uppifrån och ned: konfiguration, typer, hjälpfunktioner, tokenhantering,
API-klient, lagring av resurser och sist en main som knyter ihop allt.
Exemplet följer riktlinjerna i Integrationsguide & autentisering och
datamodellen i Media v2.2. Alla anropsmönster som beskrivs där finns med:
autentisering med token som återanvänds, hastighetsbegränsning, exponentiell
backoff, paginering och lokal lagring av filer.

Förutsättningar

  • Ett Finfo-konto med behörighet till de leverantörer du vill hämta, samt en
    client secret för API:et. Kontakta Finfo om du saknar något av detta.

  • Node 18 eller senare. Exemplet använder inbyggd fetch och har inga externa
    beroenden.

  • TypeScript 5 om du vill kompilera. Vill du bara köra direkt fungerar tsx
    eller ts-node.

Så kör du exemplet

Uppgifterna läses från miljövariabler. Lägg aldrig hemligheter i koden och
checka inte in dem i versionshantering.

Miljövariabel

Innehåll

FINFO_USERNAME

Ditt användarnamn hos Finfo

FINFO_PASSWORD

Ditt lösenord

FINFO_CLIENT_SECRET

Client secret för finfo-api

Byt finfoSupplierId i main till en leverantör du har behörighet till och
kör:
export FINFO_USERNAME=...
export FINFO_PASSWORD=...
export FINFO_CLIENT_SECRET=...
npx tsx fetch_media.ts
Körningen skriver ut hur många artiklar som hämtats i takt med att sidorna
läses in, och sparar resultatet som <finfoSupplierId>_media_<tidsstämpel>.json
i katalogen du står i. Räkna med att ett helt sortiment blir en stor fil.

Vad exemplet visar

  • Autentisering som håller under hela körningen. Första token hämtas med
    password grant, därefter förnyas den med refresh_token. Access-token
    återanvänds hela sin livstid och förnyas strax innan den går ut. Att begära
    en ny token per anrop är inte tillåtet.

  • Hastighetsbegränsning på klientsidan. Anropen släpps igenom i den takt
    endpointen tillåter, tio per sekund mot media, så att integrationen håller
    sig under gränsen även när anrop skickas parallellt.

  • Exponentiell backoff. Vid 429 och 5xx väntar klienten och gör om
    försöket, med Retry-After som utgångspunkt när servern skickar den.
    Nätverksfel behandlas på samma sätt, vilket är nödvändigt eftersom en
    hämtning av ett helt sortiment gör tusentals anrop.

  • Paginering. Sidorna läses med offset och limit och avslutas när
    totalCount är nådd. Exemplet validerar också de kombinationer av
    sökparametrar som kräver paginering innan anropet skickas.

  • Typade svar. Hela svarsstrukturen finns som TypeScript-typer, inklusive
    de tillåtna värdena för contentType, vilket gör det tydligt vilka fält som
    är valfria.

  • Lagring av filer med deduplicering. Resurser laddas ner och namnges efter
    sin checksum, så att samma fil inte hämtas två gånger när den förekommer på
    flera artiklar.

Anpassa till din integration

Det som skiljer exemplet från en färdig integration är främst att ingenting
sparas mellan körningar. I skarp drift vill du:

  • Hämta inkrementellt i stället för allt. Anropa med changedFromDate satt
    till tidpunkten för din senaste lyckade körning, och spara den tidpunkten
    hos dig. Skicka inte changedToDate i onödan, den antas vara nu.

  • Spara de checksummor du redan har lagrat, och skicka in dem till
    downloadAssets, så hämtas bara nya och ändrade filer.

  • Sänka OTHER_REQUESTS_PER_SECOND till 6 om samma klient även anropar
    article, etim, environment eller supplier, eftersom gränsen då är lägre.

  • Ersätta console.log med er egen loggning och writeFile med skrivning mot
    er databas eller ert PIM.

Viktigt att känna till

  • Resurser måste lagras lokalt. Direktlänkning till asset-URL:er i publika
    miljöer, till exempel en e-handelsplattform, är inte tillåten. Ladda ner
    filerna och publicera dem från er egen lagring.

  • 204 betyder att det inte finns någon data för de sökparametrar du angav.
    Det är inte ett fel, och exemplet hanterar det som ett tomt svar.

  • 403 betyder att behörighet saknas för den efterfrågade leverantören.
    Exemplet skiljer det från övriga fel så att en körning över flera
    leverantörer kan hoppa över dem du inte har tillgång till.

  • Filtrering på era leverantörer sker bara vid changed-from-date. Använder
    du andra sökparametrar kan svaret innehålla artiklar utanför era
    leverantörer. Matcha då mot finfoArticleId.

  • Bilder kan hämtas i tre format genom att byta ut segmentet i URL:en:
    original, preview (1000 × 1000 px JPEG) och ecom (1000 × 1000 px WebP).

Exempelkod

TypeScript
/**
 * Exempel: hämta media från Finfo API v2.2.
 *
 * Följer riktlinjerna i "Integrationsguide & autentisering 2.1" och
 * datamodellen i "Media v2.2":
 *   - OAuth2 Password Grant för första token, därefter refresh_token
 *   - Access-token återanvänds hela sin livstid (15 min). Ny token per
 *     anrop är inte tillåtet.
 *   - Exponentiell backoff vid 429: start 1 s, dubbling, tak 60 s
 *   - Exponentiell backoff vid 429/503 mot SSO: start 5 s, dubbling, tak 60 s
 *   - Omförsök vid nätverksfel, som är att räkna med under långa körningar
 *   - Klientsidig hastighetsbegränsning: 10 anrop/sekund mot media-endpoints
 *   - Paginering med offset/limit, obligatoriskt när finfoSupplierId anges
 *     utan kompletterande variabel eller när changed-from-date används
 *   - Lokal lagring av resurser med deduplicering på checksum
 *
 * Kräver Node 18 eller senare (inbyggd fetch). Inga externa beroenden.
 * Kompilera med TypeScript 5 eller kör direkt via tsx/ts-node.
 */

import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";

// ---------------------------------------------------------------------------
// Konfiguration
// ---------------------------------------------------------------------------

const TOKEN_URL =
  "https://sso.logiq.no/auth/realms/finfo/protocol/openid-connect/token";
const API_BASE = "https://api.finfo.se/api";

const CLIENT_ID = "finfo-api";
const SCOPE = "openid";

/** Lägg aldrig hemligheter i koden. Läs dem från miljövariabler. */
const CLIENT_SECRET = requireEnv("FINFO_CLIENT_SECRET");
const USERNAME = requireEnv("FINFO_USERNAME");
const PASSWORD = requireEnv("FINFO_PASSWORD");

/** Antal artiklar per sida. */
const PAGE_SIZE = 1000;

/** Media-endpoints tillåter 10 anrop per sekund. */
const MEDIA_REQUESTS_PER_SECOND = 10;

/** Byt till 6 om samma klient även anropar article/etim/environment/supplier. */
const OTHER_REQUESTS_PER_SECOND = 6;

/** Förnya token i förväg så att den inte hinner löpa ut mitt i ett anrop. */
const TOKEN_EXPIRY_MARGIN_MS = 60_000;

/** Backoff enligt integrationsguiden. */
const API_BACKOFF_START_MS = 1_000;
const SSO_BACKOFF_START_MS = 5_000;
const BACKOFF_MAX_MS = 60_000;
const MAX_ATTEMPTS = 6;

/**
 * Skickas som lastModified och createdDate när resursen inte har förändrats
 * sedan Finfo började lagra tidsstämplar.
 */
const UNKNOWN_DATE_PREFIX = "1900-01-01";

// ---------------------------------------------------------------------------
// Typer för autentisering
// ---------------------------------------------------------------------------

interface TokenResponse {
  access_token: string;
  expires_in: number;
  refresh_token: string;
  refresh_expires_in: number;
  token_type: string;
}

interface StoredTokens {
  accessToken: string;
  refreshToken: string;
  /** Tidpunkt i ms sedan epoch då access-token löper ut. */
  accessExpiresAt: number;
  /** Tidpunkt i ms sedan epoch då refresh-token löper ut. */
  refreshExpiresAt: number;
}

// ---------------------------------------------------------------------------
// Typer för media v2.2
// ---------------------------------------------------------------------------

type ImageContentType =
  | "productImageSingle"
  | "inContextProductImage"
  | "productInActionImage"
  | "productImageGroup"
  | "brandLogo"
  | "lineDrawing"
  | "colorSampleImage"
  | "productSurfaceImage";

type DocumentContentType =
  | "safetyDataSheet"
  | "productDataSheet"
  | "userManual"
  | "explodedView"
  | "productCatalog"
  | "performanceDeclaration"
  | "installationInstructions"
  | "environmentalProductDeclaration"
  | "constructionProductDeclaration"
  | "certificate"
  | "operationAndMaintenance"
  | "disassemblyInstructions";

type DesignFileContentType = "cadFile";

type EnergyLabelContentType =
  | "energyLabelingStickerPdf"
  | "energyLabelingStickerImage"
  | "productDataSheetEnergyLabeling";

type AssetContentType =
  | ImageContentType
  | DocumentContentType
  | DesignFileContentType
  | EnergyLabelContentType;

/** Gemensam struktur för alla länkade resurser. */
interface MediaAsset<T extends AssetContentType = AssetContentType> {
  url: string;
  contentType: T;
  /** AI-genererad alt-text. Tom sträng för dokument och andra filer. */
  altText: string;
  /** Datum i formatet 2025-03-28T12:56:00.0000000, eller 1900-01-01 om okänt. */
  lastModified: string;
  /** MD5-liknande summa. Använd för deduplicering vid lagring. */
  checksum: string;
  /** Löpnummer delat inom respektive länktyp. */
  linkIndex: number;
  createdDate: string;
}

interface ArticleIdentifiers {
  finfoSupplierId: number;
  supplierArticleId: string;
  /** GTIN saknas för vissa artiklar och är inte unikt mellan leverantörer. */
  gtin?: string;
}

interface ArticleText {
  webName?: string;
  ingress?: string;
  complete?: string;
  /** Radbrutna punkter, separerade med \n. */
  bullet?: string;
  /** AI-genererad text baserad på ingress och complete. */
  aiText?: string;
  /** AI-genererade USP:ar, en per rad, alltid på svenska. */
  aiBullet?: string;
}

interface ExternalAssets {
  youtubeInstallation?: string;
  youtubeMarketing?: string;
}

interface MediaArticle {
  finfoArticleId: number;
  identifiers: ArticleIdentifiers;
  lastModified: string;
  model?: string;
  text?: ArticleText;
  /** Alltid productImageSingle med linkIndex 0. Högst en per artikel. */
  heroImage?: MediaAsset<"productImageSingle"> | null;
  images?: MediaAsset<ImageContentType>[];
  /** Samtliga dokument är PDF. */
  documents?: MediaAsset<DocumentContentType>[];
  energyLabels?: MediaAsset<EnergyLabelContentType>[];
  designFiles?: MediaAsset<DesignFileContentType>[];
  externalAssets?: ExternalAssets;
  /** Reservdelar och tillbehör anges som finfoArticleId. */
  spareParts?: number[];
  accessories?: number[];
}

interface Pagination {
  offset: number;
  limit: number;
  totalCount: number;
}

interface MediaResponse {
  pagination?: Pagination;
  articleList?: MediaArticle[];
}

/**
 * Sökparametrar för /v2.2/media.
 *
 * Tidsbaserad hämtning:
 *   - Anges inget klockslag beaktas endast datumet.
 *   - Både from och to är inkluderande.
 *   - Klockslag ska anges i UTC, till exempel 2026-08-15T12:17:30.974Z.
 *   - Anges endast from antas to vara nuvarande tidpunkt. Skicka därför
 *     inte changedToDate i onödan.
 *
 * Filtrering på mottagarens leverantörer:
 *   - Används changed-from-date filtreras svaret på mottagarens egna
 *     leverantörer.
 *   - Används andra variabler kan svaret innehålla artiklar som ligger
 *     utanför mottagarens leverantörer. Matcha då mot finfoArticleId.
 */
interface MediaQuery {
  finfoSupplierId?: number;
  /** Kräver att finfoSupplierId också anges. */
  supplierArticleId?: string;
  finfoArticleId?: number;
  /** GTIN är inte unikt mellan leverantörer och kan ge flera träffar. */
  gtin?: string;
  changedFromDate?: string | Date;
  changedToDate?: string | Date;
}

interface PageRequest {
  offset: number;
  limit: number;
}

/** Bildformat som kan begäras genom att byta ut segmentet i URL:en. */
type ImageVariant = "original" | "preview" | "ecom";

/** Kastas vid 403. Åtkomst saknas för den efterfrågade leverantören. */
class AccessDeniedError extends Error {}

/** Kastas vid fel som inte går att lösa med omförsök. */
class FinfoApiError extends Error {
  constructor(
    message: string,
    readonly status: number,
    readonly body?: string,
  ) {
    super(message);
  }
}

// ---------------------------------------------------------------------------
// Hjälpfunktioner
// ---------------------------------------------------------------------------

function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Miljövariabeln ${name} saknas.`);
  }
  return value;
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
 * Beskriver ett nätverksfel. fetch kastar ett kortfattat "fetch failed" och
 * lägger den verkliga orsaken, till exempel en timeout, i cause.
 */
function describeError(error: unknown): string {
  if (!(error instanceof Error)) return String(error);
  const cause = error.cause;
  return cause instanceof Error
    ? `${error.message}: ${cause.message}`
    : error.message;
}

/**
 * Tolkar tidsstämplar från API:et. Returnerar null när värdet är
 * platshållaren 1900-01-01, vilket betyder att resursen inte har ändrats
 * sedan Finfo började lagra tidsstämplar.
 *
 * Tidsstämplarna saknar tidszon och tolkas som UTC. De sju decimalerna
 * kortas till millisekunder eftersom Date inte hanterar högre precision.
 */
function parseAssetDate(value: string | undefined): Date | null {
  if (!value || value.startsWith(UNKNOWN_DATE_PREFIX)) return null;
  const normalized = value.replace(/(\.\d{3})\d*$/, "$1");
  const withZone = /[Zz]|[+-]\d{2}:\d{2}$/.test(normalized)
    ? normalized
    : `${normalized}Z`;
  const date = new Date(withZone);
  return Number.isNaN(date.getTime()) ? null : date;
}

/**
 * Byter bildformat i en asset-URL. Gäller endast bilder.
 *   preview returnerar 1000x1000 px JPEG
 *   ecom returnerar 1000x1000 px WebP
 */
function imageUrl(url: string, variant: ImageVariant): string {
  return url.replace("/original/", `/${variant}/`);
}

/** Delar upp bullet och aiBullet i en lista. */
function parseBullets(value: string | undefined): string[] {
  if (!value) return [];
  return value
    .split("\n")
    .map((row) => row.trim())
    .filter((row) => row.length > 0);
}

/**
 * Namnen på sökparametrarna i URL:en. Samlade på ett ställe eftersom
 * skrivsättet skiljer sig mellan parametrarna.
 */
const MEDIA_PARAM_NAMES = {
  finfoSupplierId: "finfosupplierid",
  supplierArticleId: "supplierarticleid",
  finfoArticleId: "finfoarticleid",
  gtin: "gtin",
  changedFromDate: "changed-from-date",
  changedToDate: "changed-to-date",
} as const;

/**
 * Formaterar ett datum eller en tidpunkt för API:et.
 *
 * En Date skickas som fullständig UTC-tidsstämpel. En sträng skickas vidare
 * som den är, vilket gör att både 2026-08-15 och 2026-08-15T12:17:30.974Z
 * fungerar. Saknar strängen tidszon läggs Z till, eftersom API:et förväntar
 * sig UTC.
 */
function formatQueryDate(value: string | Date): string {
  if (value instanceof Date) return value.toISOString();

  const trimmed = value.trim();
  if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return trimmed;
  if (/[Zz]|[+-]\d{2}:\d{2}$/.test(trimmed)) return trimmed;
  return `${trimmed}Z`;
}

/**
 * Paginering är obligatorisk när finfoSupplierId används utan kompletterande
 * variabel, och när changed-from-date används.
 */
function requiresPagination(query: MediaQuery): boolean {
  const supplierAlone =
    query.finfoSupplierId !== undefined &&
    query.supplierArticleId === undefined &&
    query.finfoArticleId === undefined &&
    query.gtin === undefined;

  return supplierAlone || query.changedFromDate !== undefined;
}

/**
 * Bygger sökparametrarna och validerar de beroenden som API:et kräver
 * innan anropet skickas, istället för att låta servern svara med ett fel.
 */
function buildMediaParams(
  query: MediaQuery,
  page?: PageRequest,
): Record<string, string | number> {
  if (
    query.finfoSupplierId === undefined &&
    query.supplierArticleId === undefined &&
    query.finfoArticleId === undefined &&
    query.gtin === undefined &&
    query.changedFromDate === undefined
  ) {
    throw new Error(
      "Ange minst en av finfoSupplierId, supplierArticleId, " +
        "finfoArticleId, gtin eller changedFromDate.",
    );
  }

  if (
    query.supplierArticleId !== undefined &&
    query.finfoSupplierId === undefined
  ) {
    throw new Error(
      "supplierArticleId kräver att finfoSupplierId också anges.",
    );
  }

  if (requiresPagination(query) && !page) {
    throw new Error(
      "Paginering krävs för den här kombinationen av sökparametrar. " +
        "Ange offset och limit.",
    );
  }

  const params: Record<string, string | number> = {};

  if (query.finfoSupplierId !== undefined) {
    params[MEDIA_PARAM_NAMES.finfoSupplierId] = query.finfoSupplierId;
  }
  if (query.supplierArticleId !== undefined) {
    params[MEDIA_PARAM_NAMES.supplierArticleId] = query.supplierArticleId;
  }
  if (query.finfoArticleId !== undefined) {
    params[MEDIA_PARAM_NAMES.finfoArticleId] = query.finfoArticleId;
  }
  if (query.gtin !== undefined) {
    params[MEDIA_PARAM_NAMES.gtin] = query.gtin;
  }
  if (query.changedFromDate !== undefined) {
    params[MEDIA_PARAM_NAMES.changedFromDate] = formatQueryDate(
      query.changedFromDate,
    );
  }
  if (query.changedToDate !== undefined) {
    params[MEDIA_PARAM_NAMES.changedToDate] = formatQueryDate(
      query.changedToDate,
    );
  }
  if (page) {
    params.offset = page.offset;
    params.limit = page.limit;
  }

  return params;
}

/** Samlar alla länkade resurser för en artikel i en enda lista. */
function collectAssets(article: MediaArticle): MediaAsset[] {
  return [
    ...(article.heroImage ? [article.heroImage] : []),
    ...(article.images ?? []),
    ...(article.documents ?? []),
    ...(article.energyLabels ?? []),
    ...(article.designFiles ?? []),
  ];
}

/**
 * Läser Retry-After om servern skickar den, annars används den
 * beräknade backoff-tiden.
 */
function retryAfterMs(response: Response, fallbackMs: number): number {
  const header = response.headers.get("retry-after");
  if (!header) return fallbackMs;

  const seconds = Number(header);
  if (Number.isFinite(seconds)) {
    return Math.min(seconds * 1000, BACKOFF_MAX_MS);
  }

  const date = Date.parse(header);
  if (!Number.isNaN(date)) {
    return Math.min(Math.max(date - Date.now(), 0), BACKOFF_MAX_MS);
  }

  return fallbackMs;
}

/**
 * Enkel hastighetsbegränsare som håller ett minsta intervall mellan anrop.
 * Håller klienten under den tillåtna anropsfrekvensen även om anropen
 * skickas parallellt.
 */
class RateLimiter {
  private readonly minIntervalMs: number;
  private queue: Promise<void> = Promise.resolve();
  private lastStart = 0;

  constructor(requestsPerSecond: number) {
    this.minIntervalMs = Math.ceil(1000 / requestsPerSecond);
  }

  /** Väntar tills nästa anrop får skickas. */
  acquire(): Promise<void> {
    const wait = this.queue.then(async () => {
      const elapsed = Date.now() - this.lastStart;
      if (elapsed < this.minIntervalMs) {
        await sleep(this.minIntervalMs - elapsed);
      }
      this.lastStart = Date.now();
    });

    // Fel i ett tidigare anrop ska inte fastna i kön.
    this.queue = wait.catch(() => undefined);
    return wait;
  }
}

// ---------------------------------------------------------------------------
// Tokenhantering
// ---------------------------------------------------------------------------

/**
 * Håller en access-token vid liv under hela körningen.
 *
 * Första anropet använder Password Grant. Därefter förnyas token med
 * refresh_token. Password Grant används igen först när refresh-token
 * har löpt ut eller nekas av SSO.
 */
class TokenManager {
  private tokens: StoredTokens | null = null;
  private pending: Promise<StoredTokens> | null = null;

  /** Returnerar en giltig access-token, och förnyar den vid behov. */
  async getAccessToken(): Promise<string> {
    if (this.tokens && Date.now() < this.tokens.accessExpiresAt) {
      return this.tokens.accessToken;
    }

    // Se till att parallella anrop delar på en och samma förnyelse
    // istället för att begära flera tokens samtidigt.
    this.pending ??= this.renew().finally(() => {
      this.pending = null;
    });

    const tokens = await this.pending;
    return tokens.accessToken;
  }

  /**
   * Markerar nuvarande access-token som ogiltig. Anropas vid 401 så att
   * nästa anrop hämtar en ny token.
   */
  invalidate(): void {
    if (this.tokens) {
      this.tokens.accessExpiresAt = 0;
    }
  }

  private async renew(): Promise<StoredTokens> {
    const current = this.tokens;

    if (current && Date.now() < current.refreshExpiresAt) {
      try {
        this.tokens = await this.request({
          grant_type: "refresh_token",
          refresh_token: current.refreshToken,
          scope: SCOPE,
        });
        return this.tokens;
      } catch (error) {
        console.warn(
          `Kunde inte använda refresh_token (${(error as Error).message}). ` +
            "Faller tillbaka på password grant.",
        );
      }
    }

    this.tokens = await this.request({
      grant_type: "password",
      username: USERNAME,
      password: PASSWORD,
      scope: SCOPE,
    });
    return this.tokens;
  }

  /**
   * Anropar SSO med exponentiell backoff. Vid 429 och 503 är starttiden
   * 5 sekunder enligt integrationsguiden.
   */
  private async request(body: Record<string, string>): Promise<StoredTokens> {
    const basicAuth = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString(
      "base64",
    );

    let delay = SSO_BACKOFF_START_MS;

    for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
      let response: Response;

      try {
        response = await fetch(TOKEN_URL, {
          method: "POST",
          headers: {
            Authorization: `Basic ${basicAuth}`,
            "Content-Type": "application/x-www-form-urlencoded",
          },
          body: new URLSearchParams(body),
        });
      } catch (error) {
        if (attempt === MAX_ATTEMPTS) throw error;

        console.warn(
          `Anropet till SSO nådde inte fram (${describeError(error)}). ` +
            `Nytt försök om ${delay / 1000} s ` +
            `(försök ${attempt} av ${MAX_ATTEMPTS}).`,
        );
        await sleep(delay);
        delay = Math.min(delay * 2, BACKOFF_MAX_MS);
        continue;
      }

      if (response.ok) {
        const data = (await response.json()) as TokenResponse;
        const now = Date.now();
        return {
          accessToken: data.access_token,
          refreshToken: data.refresh_token,
          accessExpiresAt:
            now + data.expires_in * 1000 - TOKEN_EXPIRY_MARGIN_MS,
          refreshExpiresAt:
            now + data.refresh_expires_in * 1000 - TOKEN_EXPIRY_MARGIN_MS,
        };
      }

      const retryable = response.status === 429 || response.status === 503;
      const detail = await response.text();

      if (!retryable || attempt === MAX_ATTEMPTS) {
        throw new FinfoApiError(
          `Autentisering misslyckades med status ${response.status}.`,
          response.status,
          detail,
        );
      }

      const wait = retryAfterMs(response, delay);
      console.warn(
        `SSO svarade ${response.status}. Nytt försök om ${wait / 1000} s ` +
          `(försök ${attempt} av ${MAX_ATTEMPTS}).`,
      );
      await sleep(wait);
      delay = Math.min(delay * 2, BACKOFF_MAX_MS);
    }

    throw new Error("Kunde inte hämta access-token.");
  }
}

// ---------------------------------------------------------------------------
// API-klient
// ---------------------------------------------------------------------------

class FinfoClient {
  private readonly tokens = new TokenManager();
  private readonly mediaLimiter = new RateLimiter(MEDIA_REQUESTS_PER_SECOND);
  private readonly defaultLimiter = new RateLimiter(OTHER_REQUESTS_PER_SECOND);

  /**
   * GET mot Finfo API med backoff. Returnerar null vid 204 No Content.
   *
   * Backoff startar på 1 sekund, dubblas vid varje ny 429 och har ett tak
   * på 60 sekunder. Eftersom väntetiden är lokal för varje anrop nollställs
   * den automatiskt så snart ett anrop lyckas.
   *
   * Nätverksfel behandlas som övergående och görs om med samma backoff. En
   * hämtning av ett helt sortiment gör tusentals anrop, och då inträffar
   * enstaka avbrutna anslutningar förr eller senare.
   */
  private async get<T>(
    endpoint: string,
    params: Record<string, string | number>,
    limiter: RateLimiter,
  ): Promise<T | null> {
    const url = new URL(`${API_BASE}${endpoint}`);
    for (const [key, value] of Object.entries(params)) {
      url.searchParams.set(key, String(value));
    }

    let delay = API_BACKOFF_START_MS;
    let reauthorized = false;

    for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
      await limiter.acquire();

      const token = await this.tokens.getAccessToken();

      let response: Response;

      try {
        response = await fetch(url, {
          headers: {
            Authorization: `Bearer ${token}`,
            Accept: "application/json",
          },
        });
      } catch (error) {
        if (attempt === MAX_ATTEMPTS) throw error;

        console.warn(
          `Anropet nådde inte fram (${describeError(error)}). ` +
            `Nytt försök om ${delay / 1000} s ` +
            `(försök ${attempt} av ${MAX_ATTEMPTS}).`,
        );
        await sleep(delay);
        delay = Math.min(delay * 2, BACKOFF_MAX_MS);
        continue;
      }

      switch (response.status) {
        case 200:
          return (await response.json()) as T;

        case 204:
          // Ingen data för de angivna parametrarna.
          return null;

        case 401:
          // Token underkändes. Hämta en ny och gör ett omförsök.
          if (reauthorized) {
            throw new FinfoApiError("Åtkomst nekad efter förnyad token.", 401);
          }
          reauthorized = true;
          this.tokens.invalidate();
          continue;

        case 403:
          throw new AccessDeniedError(`Åtkomst nekad för ${url.pathname}.`);

        case 404:
          throw new FinfoApiError(`Felaktig URL: ${url.pathname}.`, 404);

        case 429:
        case 500:
        case 502:
        case 503:
        case 504: {
          if (attempt === MAX_ATTEMPTS) {
            throw new FinfoApiError(
              `Gav upp efter ${MAX_ATTEMPTS} försök.`,
              response.status,
            );
          }
          const wait = retryAfterMs(response, delay);
          console.warn(
            `Status ${response.status}. Nytt försök om ${wait / 1000} s ` +
              `(försök ${attempt} av ${MAX_ATTEMPTS}).`,
          );
          await sleep(wait);
          delay = Math.min(delay * 2, BACKOFF_MAX_MS);
          continue;
        }

        default:
          throw new FinfoApiError(
            `Oväntad status ${response.status}.`,
            response.status,
            await response.text(),
          );
      }
    }

    throw new Error(`Kunde inte hämta ${url.pathname}.`);
  }

  /**
   * Hämtar en sida med media. Anges ingen sida skickas anropet utan
   * offset och limit, vilket bara är tillåtet för de kombinationer av
   * sökparametrar som inte kräver paginering.
   */
  async fetchMediaPage(
    query: MediaQuery,
    page?: PageRequest,
  ): Promise<MediaResponse | null> {
    return this.get<MediaResponse>(
      "/v2.2/media",
      buildMediaParams(query, page),
      this.mediaLimiter,
    );
  }

  /**
   * Itererar över samtliga artiklar med media, en sida i taget. Använd
   * denna om du vill bearbeta löpande istället för att hålla hela svaret
   * i minnet.
   */
  async *iterateMedia(
    query: MediaQuery,
    limit: number = PAGE_SIZE,
  ): AsyncGenerator<MediaArticle[], void, void> {
    // Frågor mot en enskild artikel behöver ingen paginering.
    if (!requiresPagination(query)) {
      const page = await this.fetchMediaPage(query);
      const articles = page?.articleList ?? [];
      if (articles.length > 0) yield articles;
      return;
    }

    let offset = 0;
    let fetched = 0;

    while (true) {
      const page = await this.fetchMediaPage(query, { offset, limit });

      const articles = page?.articleList ?? [];
      if (articles.length === 0) break;

      fetched += articles.length;
      yield articles;

      const totalCount = page?.pagination?.totalCount;
      if (typeof totalCount === "number" && fetched >= totalCount) break;

      // En ofylld sida betyder att vi har nått slutet.
      if (articles.length < limit) break;
      offset += limit;
    }
  }

  /** Hämtar samtliga artiklar som matchar sökparametrarna. */
  async fetchAllMedia(query: MediaQuery): Promise<MediaArticle[]> {
    const articles: MediaArticle[] = [];

    for await (const page of this.iterateMedia(query)) {
      articles.push(...page);
      console.log(`${articles.length} artiklar hämtade.`);
    }

    return articles;
  }

  /** Leverantörer som den inloggade användaren har åtkomst till. */
  async fetchMySuppliers(): Promise<{ finfoSupplierId: number }[]> {
    const suppliers = await this.get<{ finfoSupplierId: number }[]>(
      "/v1.0/suppliers/mysuppliers",
      {},
      this.defaultLimiter,
    );
    return suppliers ?? [];
  }
}

// ---------------------------------------------------------------------------
// Lokal lagring av resurser
// ---------------------------------------------------------------------------

/**
 * Laddar ner resurser till disk.
 *
 * Resurser måste lagras lokalt. Direktlänkning till asset-URL:er i publika
 * miljöer, till exempel en e-handelsplattform, är inte tillåten.
 *
 * Samma fil kan förekomma på flera artiklar. Kontrollera därför checksum
 * innan nedladdning och länka en redan lagrad kopia istället för att hämta
 * filen igen.
 */
async function downloadAssets(
  articles: MediaArticle[],
  targetDir: string,
  knownChecksums: Set<string> = new Set(),
): Promise<{ downloaded: number; skipped: number }> {
  await mkdir(targetDir, { recursive: true });

  let downloaded = 0;
  let skipped = 0;

  for (const article of articles) {
    for (const asset of collectAssets(article)) {
      if (knownChecksums.has(asset.checksum)) {
        skipped++;
        continue;
      }

      const extension = path.extname(new URL(asset.url).pathname) || ".bin";
      const fileName = `${asset.checksum}${extension}`;

      const response = await fetch(asset.url);
      if (!response.ok) {
        console.warn(
          `Kunde inte hämta ${asset.url} (status ${response.status}).`,
        );
        continue;
      }

      const buffer = Buffer.from(await response.arrayBuffer());
      await writeFile(path.join(targetDir, fileName), buffer);

      knownChecksums.add(asset.checksum);
      downloaded++;
    }
  }

  return { downloaded, skipped };
}

// ---------------------------------------------------------------------------
// Körning
// ---------------------------------------------------------------------------

async function main(): Promise<void> {
  const client = new FinfoClient();
  const finfoSupplierId = 78604891;

  let articles: MediaArticle[];

  try {
    // Utan datumparametrar hämtas hela sortimentet. För stora leverantörer
    // är det ofta effektivare än att filtrera på ett brett datumintervall.
    articles = await client.fetchAllMedia({ finfoSupplierId });

    // Andra sökningar som endpointen stöder:
    //
    // Ändringar sedan en tidpunkt. Svaret filtreras på mottagarens egna
    // leverantörer. Skicka inte changedToDate, den antas vara nu.
    //   await client.fetchAllMedia({
    //     changedFromDate: new Date(Date.now() - 24 * 60 * 60 * 1000),
    //   });
    //
    // En enskild artikel, ingen paginering krävs:
    //   await client.fetchAllMedia({ finfoArticleId: 5061033 });
    //
    // Leverantörens eget artikelnummer, kräver finfoSupplierId:
    //   await client.fetchAllMedia({
    //     finfoSupplierId,
    //     supplierArticleId: "123456789012345678",
    //   });
  } catch (error) {
    if (error instanceof AccessDeniedError) {
      console.error(error.message);
      return;
    }
    throw error;
  }

  if (articles.length === 0) {
    console.log(`Ingen media hittades för leverantör ${finfoSupplierId}.`);
    return;
  }

  const timestamp = new Date()
    .toISOString()
    .replace(/[-:T.]/g, "")
    .slice(0, 14);
  const fileName = `${finfoSupplierId}_media_${timestamp}.json`;
  await writeFile(fileName, JSON.stringify(articles, null, 2), "utf-8");

  console.log(`${articles.length} artiklar sparade till ${fileName}.`);

  // Exempel på hur enskilda fält används.
  const sample = articles[0];
  console.log({
    finfoArticleId: sample.finfoArticleId,
    supplierArticleId: sample.identifiers.supplierArticleId,
    webName: sample.text?.webName,
    bullets: parseBullets(sample.text?.aiBullet),
    heroImage: sample.heroImage
      ? {
          original: sample.heroImage.url,
          ecom: imageUrl(sample.heroImage.url, "ecom"),
          altText: sample.heroImage.altText,
          lastModified: parseAssetDate(sample.heroImage.lastModified),
        }
      : null,
    safetyDataSheets: (sample.documents ?? [])
      .filter((doc) => doc.contentType === "safetyDataSheet")
      .map((doc) => doc.url),
    spareParts: sample.spareParts ?? [],
  });

  // Ladda ner och lagra resurserna lokalt. Skicka in de checksummor som
  // redan finns i ert system för att hoppa över filer ni har.
  //
  // const result = await downloadAssets(articles, `./assets/${finfoSupplierId}`);
  // console.log(`${result.downloaded} filer hämtade, ${result.skipped} överhoppade.`);

  // Variant: gå igenom samtliga leverantörer som användaren har åtkomst till.
  //
  // for (const supplier of await client.fetchMySuppliers()) {
  //   try {
  //     const media = await client.fetchAllMedia({
  //       finfoSupplierId: supplier.finfoSupplierId,
  //     });
  //     console.log(`${supplier.finfoSupplierId}: ${media.length} artiklar.`);
  //   } catch (error) {
  //     if (error instanceof AccessDeniedError) continue;
  //     throw error;
  //   }
  // }
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});