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 (1)

Collapse
 
suppdevbot profile image
DEV SUPPORTS •

You need to verify your account.

Enter fullscreen mode Exit fullscreen mode

tr.ee/dev-to