DEV Community

Oleksandr
Oleksandr

Posted on

I built 100 compliance screening pages with one command (OFAC, PEP, entity)

This is the 5th stream in a series. Previous ones: HS codes, document parsing, finance validators, watch/monitoring. This time β€” compliance screening against OFAC, EU, UK, UN sanctions lists, plus PEP screening and entity verification.

Same playbook each time: one Cloudflare Worker + one JSON list + one generator script β†’ 100 SEO pages + landing + Suby product.

Live: https://jsonexlab.com/compliance

What it does

Screen any person or company against:

  • OFAC SDN (US Treasury, 19,416 entries)
  • OFAC Consolidated (non-SDN)
  • UK OFSI (UK sanctions)
  • EU Consolidated (EU sanctions)
  • UN Security Council (UN sanctions)
  • PEP (Politically Exposed Persons)
  • Entity verification (company registries: US-DE, UK, DE, UA, etc.)

Live demo:

curl "https://compliance-engine.boring-saas-infra.workers.dev/check/sanctions?q=Rosneft"
Enter fullscreen mode Exit fullscreen mode

β†’

{
  "check_type": "SANCTIONS",
  "query": "Rosneft",
  "status": "HIT",
  "matches": true,
  "results": [
    {
      "list": "OFAC_SDN",
      "name": "OPEN JOINT-STOCK COMPANY ROSNEFT OIL COMPANY",
      "type": "entity",
      "country": "Russia",
      "programs": ["UKRAINE-EO13662", "RUSSIA-EO14024"],
      "ids": [
        {"type": "Registration ID", "number": "1027700043502"},
        {"type": "Tax ID No.", "number": "7706107510"},
        {"type": "Website", "number": "www.rosneft.com"}
      ],
      "addresses": [{"street": "26/1 Sofiyskaya Embankment", "city": "Moscow", "country": "Russia"}],
      "score": 1
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Why this one was harder

Three things made this stream different from previous ones:

1. Cloudflare Workers cannot fetch OFAC directly

OFAC lives on sanctionslistservice.ofac.treas.gov. Cloudflare Workers fails with HTTP 525 (SSL handshake error) or 403 (missing User-Agent). This is a TLS incompatibility on the origin side β€” nothing you can fix from the Worker.

Solution: move ingestion to GitHub Actions (6 GB RAM, normal TLS stack, works with any URL).

2. 30 MB XML doesn't fit in 128 MB Worker RAM

Naive approach β€” fetch XML β†’ parse β†’ insert β€” dies with error 1102 (Worker exceeded resource limits).

Solution: GitHub Actions downloads the XML, parses it in Node.js, and inserts directly into Cloudflare D1 via the D1 HTTP API.

3. D1 free tier has a daily row-write limit

100,000 row writes per day. A single OFAC ingestion = 19,416 rows Γ— 2 (UPSERT counts as insert + update) = ~39,000 writes. Two ingestions in one day β†’ quota hit.

Solution: cron runs once per day at 01:00 UTC. Quota resets at midnight UTC, so the ingestion always fits.

The architecture

GitHub Actions (cron 0 1 * * *)
    β”‚
    β”œβ”€β”€ Download OFAC SDN XML (30 MB) via curl
    β”œβ”€β”€ Download UK OFSI CSV (50 MB) via curl
    β”œβ”€β”€ Parse in Node.js (6 GB RAM available)
    └── POST to Cloudflare D1 HTTP API
            β”‚
            β–Ό
    Cloudflare D1 (compliance-db)
        β”œβ”€β”€ sanctions (19,416+ entries)
        β”œβ”€β”€ pep
        β”œβ”€β”€ entity
        β”œβ”€β”€ license
        β”œβ”€β”€ runs (ingestion log)
        └── search_log
            β”‚
            β–Ό
    Cloudflare Worker (compliance-engine)
        β”œβ”€β”€ GET /check/sanctions?q=...
        β”œβ”€β”€ GET /check/pep?q=...
        β”œβ”€β”€ GET /check/entity?q=...
        β”œβ”€β”€ GET /stats
        └── Cron triggers (legacy, not used)
            β”‚
            β–Ό
    Cloudflare Pages (jsonexlab.com)
        β”œβ”€β”€ /compliance (landing)
        β”œβ”€β”€ /compliance/{slug} (100 SEO pages)
        └── /sitemap.xml (729 URLs)
Enter fullscreen mode Exit fullscreen mode

The GitHub Actions workflow

.github/workflows/ingest-ofac.yml:

name: Ingest OFAC Sanctions

on:
  schedule:
    - cron: '0 1 * * *'
  workflow_dispatch:

jobs:
  ingest:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Run OFAC ingestion
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
        run: node scripts/ingest-ofac.js
Enter fullscreen mode Exit fullscreen mode

Simple. GitHub Actions does the heavy lifting, Worker stays light.

The parser

Core is the OFAC SDN XML parser β€” regex-based, no dependencies:

function parseOneSdnEntry(entry, listCode = "OFAC_SDN") {
  const uid = extractTag(entry, "uid");
  if (!uid) return null;

  const lastName = decodeXmlEntities(extractTag(entry, "lastName") || "");
  const firstName = decodeXmlEntities(extractTag(entry, "firstName") || "");
  const fullName = [firstName, lastName].filter(Boolean).join(" ").trim() || lastName;
  if (!fullName) return null;

  const type = mapSdnType(extractTag(entry, "sdnType") || "Entity");
  const programs = extractAllTags(entry, "program");

  // aliases, ids, addresses, nationality, dob...

  return {
    list_code: listCode,
    entity_id: uid,
    name: fullName,
    name_normalized: normalizeName(fullName),
    aliases: aliases.length ? aliases : null,
    type,
    country,
    programs: programs.length ? programs : null,
    dob,
    ids: ids.length ? ids : null,
    addresses: addresses.length ? addresses : null,
    remarks: remarks || null,
  };
}
Enter fullscreen mode Exit fullscreen mode

Whole parser is ~250 lines. Handles:

  • 19,416 SDN entries
  • Aliases (aka blocks)
  • IDs (passport, tax, registration, website, email)
  • Addresses (multiple per entity)
  • Nationality / citizenship
  • Date of birth

Gotcha I hit: extractAllTags initially captured nested XML tags. <programList><program>CUBA</program></programList> returned ["<program>CUBA"] instead of ["CUBA"]. Fix: strip nested tags with .replace(/<[^>]+>/g, "").

The fuzzy matching

For a compliance tool, exact string matching is useless β€” you need fuzzy. Here's what I use in the Worker:

function matchScore(queryNorm, candidateNorm) {
  const jw = jaroWinkler(queryNorm, candidateNorm);
  const tok = tokenSimilarity(queryNorm, candidateNorm);
  const lev = levenshtein(queryNorm, candidateNorm);
  const levScore = 1 - lev / Math.max(queryNorm.length, candidateNorm.length);
  return 0.5 * tok + 0.35 * jw + 0.15 * levScore;
}
Enter fullscreen mode Exit fullscreen mode

Three algorithms weighted:

  • Token similarity (0.5) β€” handles word order ("Rosneft Oil Company" vs "Oil Company Rosneft")
  • Jaro-Winkler (0.35) β€” handles typos and short strings
  • Levenshtein (0.15) β€” tie-breaker

Plus normalization before matching:

  • Lowercase
  • ASCII-fold (Cyrillic β†’ Latin: ΠžΠ»Π΅ΠΊΡΠ°Π½Π΄Ρ€ β†’ oleksandr)
  • Strip punctuation
  • Remove legal suffixes (LLC, Inc, GmbH, PJSC, etc.)
  • Collapse whitespace

Threshold 0.82 by default. Rosneft β†’ OPEN JOINT-STOCK COMPANY ROSNEFT OIL COMPANY scores 1.0 (100% match). Gazprom β†’ CLEAR (correct β€” Gazprom itself isn't on SDN, but Gazprombank and Gazpromneft are).

The 100 SEO pages

products-compliance.json (excerpt):

[
  {"slug":"sanctions-ofac-sdn","name":"OFAC SDN Sanctions Check","type":"sanctions","category":"Sanctions","list_code":"OFAC_SDN"},
  {"slug":"sanctions-ofac-sdn-individuals","name":"OFAC SDN β€” Individuals","type":"sanctions","type_filter":"individual"},
  {"slug":"sanctions-russia","name":"Russia Sanctions Screening","type":"sanctions","program_filter":"RUSSIA"},
  {"slug":"pep-head-of-state","name":"PEP β€” Head of State","type":"pep","level":"head_of_state"},
  {"slug":"entity-verify-llc","name":"LLC Entity Verification","type":"entity","entity_type":"LLC"},
  {"slug":"verify-medical-license","name":"Medical License Verification","type":"license","license_type":"medical"}
]
Enter fullscreen mode Exit fullscreen mode

100 entries across 5 categories:

  • 30 Sanctions β€” by list (OFAC SDN, EU, UK, UN) + by type (individual, entity, vessel, aircraft) + by program (Russia, Iran, DPRK, Cuba, etc.)
  • 20 Licenses β€” medical, nursing, legal, contractor, real estate, insurance, accounting, pharmacy, dental, etc.
  • 20 Checklists β€” country + segment (Germany import, US export, fintech, crypto, healthcare, etc.)
  • 15 PEP β€” head of state, minister, parliament, judiciary, military, SOE executive, etc.
  • 15 Entity β€” LLC, Corp, PLC, Ltd, GmbH, AG, SA, SARL, BV, NV, Oy, AB, Pty, LLP, Π’ΠžΠ’

generator-compliance.cjs:

node generator-compliance.cjs
Enter fullscreen mode Exit fullscreen mode

β†’

βœ… Π—Π³Π΅Π½Π΅Ρ€ΠΎΠ²Π°Π½ΠΎ 100 сторінок Ρƒ output/compliance/
βœ… sitemap.xml ΠΎΠ½ΠΎΠ²Π»Π΅Π½ΠΎ β€” всього 729 URL
βœ… llms.txt ΠΎΠ½ΠΎΠ²Π»Π΅Π½ΠΎ β€” 100 compliance checks
Enter fullscreen mode Exit fullscreen mode

Each page has:

  • Unique <title> + meta description
  • Working live demo (hits real Worker with real D1 data)
  • Schema.org Product + BreadcrumbList + FAQPage
  • 5 FAQ items customized per check type
  • Related checks chips
  • Pricing block with Suby checkout

Pricing

  • Free β€” 5 checks/day, no API key
  • Pro ($99/mo) β€” 500 checks/mo, all 5 lists, PEP, entity, license, webhooks

Higher price than other streams ($19.99) because:

  1. Recurring compliance need (regulatory requirement)
  2. B2B audience pays more
  3. Different value prop (legal risk mitigation, not a one-shot check)

Deployment

Two Cloudflare accounts came in handy:

  • Account #1 β€” jsonexlab.com (custom domain) + Pages project
  • Account #2 β€” Workers + D1 + R2 + KV

Workers stay on *.boring-saas-infra.workers.dev, Pages on jsonexlab.com. Clean separation.

Full stack

  • Cloudflare Workers β€” API (compliance-engine, finance-validators, watch-engine)
  • Cloudflare D1 β€” SQLite storage (compliance-db: 19,416+ sanctions)
  • Cloudflare R2 β€” raw file buffer (not actively used β€” kept as fallback)
  • Cloudflare KV β€” response cache
  • Cloudflare Pages β€” static site (729 URLs)
  • GitHub Actions β€” daily ingestion (OFAC + UK OFSI)
  • Node.js generators β€” no deps, ~300 lines each
  • Suby β€” payments (crypto + card)
  • IndexNow β€” 729 URLs pinged to Bing + Yandex

Total monthly cost: $0.

What's next

  • EU + UN ingestion β€” currently only OFAC + UK
  • PEP data β€” WikiData + OpenSanctions (needs commercial licence)
  • Webhook alerts β€” get notified when a party on your list gets sanctioned
  • Google Sheets add-on β€” =SANCTIONS_CHECK(A1)

Try it

If you're building KYC, AML, or trade compliance workflows β€” give it a shot. Free tier is genuinely free.

Happy to answer questions about the Cloudflare Workers + D1 + GitHub Actions setup, or the programmatic SEO pattern.

Top comments (2)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.