diff --git a/packages/core/lib/spec-parser.ts b/packages/core/lib/spec-parser.ts
index 8ffb862..1a38ec7 100644
--- a/packages/core/lib/spec-parser.ts
+++ b/packages/core/lib/spec-parser.ts
@@ -1,37 +1,18 @@
-/**
- * Parses hardware specs out of a Paymenter product description (HTML).
- *
- * Descriptions follow consistent bullet-point patterns, e.g.:
- * "2 Cores of Ryzen 7 Power: ..."
- * "4 GB DDR4 RAM: ..."
- * "100 GB SSD Storage: ..."
- * "1 Gbps Network Port: ... 1 TB included outbound ..."
- *
- * Everything extractable comes from here; the only things left in config
- * are `popular` flags and the series→location map.
- */
export interface ParsedSpecs {
cpu?: number
ramGB?: number
- /** RAM generation if the description names one, e.g. "DDR3"/"DDR4"/"DDR5" — omitted when unspecified. */
ramType?: string
storageGB?: number
- /** Raw storage label extracted from the description, e.g. "2 × 1 TB NVMe SSD (RAID 1)" */
storageDescription?: string
- /** Which storage keyword the description actually used — "generic" when it only said e.g. "40 GB Storage Array" with no drive type. */
storageType?: "nvme" | "ssd" | "hdd" | "generic"
bandwidth?: { amount: number; unit: "MB" | "GB" | "TB" } | null
uplink?: { amount: number; unit: "Mbps" | "Gbps" }
cpuModel?: string
hardware?: "amd" | "intel" | "arm"
- /** Short description extracted from the first bullet's subtitle. */
description?: string
- /** Location(s) named in a "location"/"region" bullet, e.g. "Newcastle, UK / New York, US" — omitted if none named. */
location?: string
- /** Number of databases included, e.g. "3x MySQL Databases" → 3. */
databases?: number
- /** Whether the description mentions automatic/included backups. */
backups?: boolean
}
@@ -39,21 +20,10 @@ function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, " ").replace(/&/g, "&").replace(/\s+/g, " ").trim()
}
-/**
- * Split into individual bullet lines. Paymenter descriptions are usually a
- * `
- ` list (one bullet per `
- `); older ones instead separate
- * bullets with a "•" character or bare newlines. Insert a line break at each
- * list-item/paragraph/`
` boundary before stripping tags so line-based
- * extraction below sees one bullet per line regardless of which format the
- * description actually uses.
- */
export function bulletLines(html: string): string[] {
const withBreaks = html
.replace(/<\/(li|p|div|h[1-6])>/gi, "\n")
.replace(/
/gi, "\n")
- // Unescape "&" last — it must never run before the other entities, or
- // e.g. a literal ">" in the source (an escaped ampersand followed by
- // "gt;") would double-unescape into ">" instead of the correct ">".
const text = withBreaks
.replace(/<[^>]*>/g, " ")
.replace(/>/g, ">")
@@ -69,7 +39,6 @@ export function bulletLines(html: string): string[] {
.filter(Boolean)
}
-/** First " GB|TB" in a line (comma thousands separators allowed, e.g. "1,000 GB"), converted to GB (TB × 1024). Returns undefined if the line has none. */
export function firstSizeGB(line: string): number | undefined {
const m = line.match(/([\d,]+)\s*(GB|TB)\b/i)
if (!m) return undefined
@@ -77,70 +46,56 @@ export function firstSizeGB(line: string): number | undefined {
return /tb/i.test(m[2]) ? amount * 1024 : amount
}
+function splitSpecLine(line: string): string[] | null {
+ const parts = line
+ .split(/\s+[/|·]\s+|\s*·\s*/)
+ .map((part) => part.trim())
+ .filter(Boolean)
+ if (parts.length < 2 || !parts.every((part) => /\d/.test(part) && part.length <= 40)) return null
+ return parts
+}
+
+function isTitledBullet(line: string): boolean {
+ return /^[^:]{1,60}:\s/.test(line)
+}
+
export function parseDescriptionSpecs(html: string | null): ParsedSpecs {
if (!html) return {}
const text = stripHtml(html)
- const lines = bulletLines(html)
-
- // ── CPU cores ──────────────────────────────────────────────────────────────
- // "1 Core of Ryzen 7 Power", "2 Cores of Ryzen 7", "2 Ampere® Altra® ARM64 Cores"
- // "ELITE Resource Allocation: 8 Dedicated/Pinned Physical Cores and 16 threads"
- // Fallback: "8 cores and 16 threads", "Octa-Core", "Quad-Core", "2 vCPU"
- //
- // Deliberately NOT a "grab any number near the word core/cpu" fallback —
- // descriptions also state the CPU *model* number in the same breath
- // ("Ryzen™ 9 5900X", "Core Ultra 7 265"), and a wrong core count is worse
- // than none: it ships incorrect specs instead of just dropping the plan
- // (which surfaces via warnDroppedProduct so it gets fixed at the source).
+ const rawLines = bulletLines(html)
+ const lines = rawLines.flatMap((line) => splitSpecLine(line) ?? [line])
+
const NAMED_CORES: Record = { mono: 1, dual: 2, quad: 4, hexa: 6, octa: 8, deca: 10, dodeca: 12 }
let cpu: number | undefined
for (const line of lines) {
- const m = line.match(/^(\d+)\s+(?:\S+\s+)*?[Cc]ores?/i)
+ const m = line.match(/^(\d+)\s+(?:\S+\s+)*?v?[Cc]ores?/i)
if (m && !line.match(/^(\d+)\s+GB/i)) {
cpu = parseInt(m[1])
break
}
}
if (!cpu) {
- // Number and "Cores" in the same bullet but not at the start of it — e.g.
- // "ELITE Resource Allocation: 8 Dedicated/Pinned Physical Cores and 16
- // threads". Allow up to 4 filler words between the number and "Cores",
- // bounded per-line so it can't reach into an unrelated bullet.
- // Requires the plural "Cores", not "Core" — singular "Core" shows up in
- // non-count marketing phrases too ("Ryzen™ 9 5900X Core Processing",
- // "Core Ultra 7 265"), where the preceding number is a CPU model number,
- // not a core count, and guessing wrong is worse than leaving it unset.
for (const line of lines) {
- const m = line.match(/(\d+)\s+(?:[\w/.-]+\s+){0,4}?[Cc]ores\b/)
+ const m = line.match(/(\d+)\s+(?:[\w/.-]+\s+){0,4}?v?[Cc]ores\b/)
if (m) { cpu = parseInt(m[1]); break }
}
}
if (!cpu) {
- // "Octa-Core", "Quad-Core" etc
for (const [name, count] of Object.entries(NAMED_CORES)) {
if (new RegExp(`\\b${name}[- ]?[Cc]ore\\b`, 'i').test(text)) { cpu = count; break }
}
}
if (!cpu) {
- const m = text.match(/\b(\d+)\s*v(?:CPU|Core)s?\b/i)
+ const m = text.match(/\b(\d+)\s*(?:dedicated\s+)?v(?:CPU|Core)s?\b/i)
if (m) cpu = parseInt(m[1])
}
- // ── RAM ────────────────────────────────────────────────────────────────────
- // Any bullet mentioning "RAM" or "memory" is classified as the RAM line;
- // the first GB figure in that line is the amount, wherever it sits.
- // "2 GB DDR4 RAM", "4 GB ECC RAM", "RAM: 16GB of fast memory"
const ramLine = lines.find((line) => /\bram\b|\bmemory\b/i.test(line))
const ramGB = ramLine ? firstSizeGB(ramLine) : undefined
const ramTypeMatch = ramLine ? ramLine.match(/DDR\s?([345])/i) : null
const ramType = ramTypeMatch ? `DDR${ramTypeMatch[1]}` : undefined
- // ── Storage ────────────────────────────────────────────────────────────────
- // Any bullet mentioning a storage-ish keyword is classified as the storage
- // line; the first GB/TB figure in it is the amount. Tolerates arbitrary
- // wording/ordering — "80 GB Local NVMe Storage", "Storage: 80GB of blazing
- // SSD space", "2 x 1 TB NVMe SSD (RAID 1)" all resolve the same way.
const storageLine = lines.find((line) => /\b(?:ssd|hdd|nvme|storage|disk|drive)\b/i.test(line))
const storageGB = storageLine ? firstSizeGB(storageLine) : undefined
@@ -154,7 +109,6 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs {
: "generic"
: undefined
- // Raw storage label for multi-drive dedicated configs
const storageDescription = storageLine
? (() => {
const beforeColon = storageLine.split(':')[0].trim()
@@ -162,17 +116,11 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs {
})()
: undefined
- // ── Uplink ─────────────────────────────────────────────────────────────────
- // "1 Gbps Network Port", "4 Gbps"
const uplinkMatch = text.match(/(\d+)\s*Gbps/i)
const uplink: ParsedSpecs["uplink"] = uplinkMatch
? { amount: parseInt(uplinkMatch[1]), unit: "Gbps" }
: { amount: 1, unit: "Gbps" }
- // ── Bandwidth ──────────────────────────────────────────────────────────────
- // "1 TB included outbound", "20 TB Outbound Traffic Pool", "4 TB of poolable outbound"
- // "Unlimited Outbound Bandwidth" → null (unmetered)
- // Avoids false positives from storage descriptions ("4 x 16 TB Enterprise SATA HDDs")
const unlimitedBw = /unlimited\s+(?:\w+\s+)?bandwidth/i.test(text)
const bwTbMatch =
text.match(/(\d+)\s+TB\s+(?:included\s+)?outbound/i) ||
@@ -185,16 +133,11 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs {
? { amount: parseInt(bwTbMatch[1]), unit: "TB" }
: null
- // ── CPU model & hardware ───────────────────────────────────────────────────
let cpuModel: string | undefined
let hardware: ParsedSpecs["hardware"]
- // "AMD Ryzen™ 9 5900X", "Ryzen 3700X" (single model token, no series digit,
- // "AMD" prefix not always stated — "Ryzen" alone is unambiguously AMD)
const ryzenMatch = text.match(/(?:AMD\s+)?Ryzen[™™]?\s+(?:\d+\s+)?(?:PRO\s+)?\d+\w*/i)
const ampereMatch = text.match(/Ampere[®®]?\s+Altra[®®]?(?:\s+ARM64)?/i)
- // Intel: Xeon, Core Ultra, Core i-series — "Core" isn't always stated
- // ("Intel i9-9900K" as shorthand for "Intel Core i9-9900K")
const intelMatch = text.match(/Intel[®®]?\s+(?:Core[™™]?\s+Ultra\s+\d+(?:\s+\d+)?|(?:Core[™™]?\s+)?i\d+[- ]\d+\w*|Xeon[®®]?(?:\s+\w+)*)/i)
if (ryzenMatch) { cpuModel = ryzenMatch[0].trim(); hardware = "amd" }
@@ -206,22 +149,19 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs {
if (intelMatch) { cpuModel = intelMatch[0].trim(); hardware = "intel" }
else if (/\bintel\b/i.test(text) && !ryzenMatch && !ampereMatch) { hardware = "intel" }
- // ── Short description ──────────────────────────────────────────────────────
- // Pull the subtitle from the first bullet: "Spec Title: ."
let description: string | undefined
- for (const line of lines) {
- const m = line.match(/^.+?:\s+(.{20,}?\.)/)
- if (m) { description = m[1].trim(); break }
+ const headline = rawLines.findIndex((line) => splitSpecLine(line) !== null)
+ if (headline !== -1) {
+ const next = rawLines[headline + 1]
+ if (next && !isTitledBullet(next) && !splitSpecLine(next) && next.length >= 15) description = next
+ }
+ if (!description) {
+ for (const line of lines) {
+ const m = line.match(/^.+?:\s+(.{20,}?\.)/)
+ if (m) { description = m[1].trim(); break }
+ }
}
- // ── Location ───────────────────────────────────────────────────────────────
- // "Flexible Regional Hosting: ... choose between Newcastle, UK or New York,
- // US ..." — pull "City, XX" style place names out of any bullet mentioning
- // location/region, joining multiple choices with " / ".
- // "Flexible Regional Hosting: ... Newcastle, UK or New York, US ..." (code)
- // "Premium Central EU Infrastructure: Provisioned in Falkenstein, Germany
- // ..." (full country name) — the trigger keyword and the place format both
- // vary, so cast a wider net on both.
let location: string | undefined
const locationLine = lines.find((line) =>
/\blocation|\bregion|\binfrastructure|\bprovisioned|\bhosted|\bdata\s*cent(?:er|re)/i.test(line),
@@ -231,32 +171,24 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs {
if (places && places.length > 0) location = places.join(" / ")
}
- // ── Databases ──────────────────────────────────────────────────────────────
- // "3x MySQL Databases", "5x MySQL Databases Included", "10 isolated MySQL databases"
let databases: number | undefined
const dbMatch = text.match(/\b(\d+)x?\s+(?:isolated\s+)?(?:MySQL\s+|PostgreSQL\s+|SQL\s+)?[Dd]atabases?\b/)
if (dbMatch) databases = parseInt(dbMatch[1])
- // ── Backups ────────────────────────────────────────────────────────────────
const backups = /\bbackups?\b/i.test(text) || undefined
return { cpu, ramGB, ramType, storageGB, storageDescription, storageType, bandwidth, uplink, cpuModel, hardware, description, location, databases, backups }
}
-/** Human-friendly storage type label — falls back to "Storage Array" when the description didn't name a drive type. */
export function formatStorageType(type: ParsedSpecs["storageType"]): string {
switch (type) {
- case "nvme": return "NVMe SSD Storage"
+ case "nvme": return "NVMe Storage"
case "ssd": return "SSD Storage"
case "hdd": return "HDD Storage"
- default: return "Storage Array"
+ default: return "Storage"
}
}
-/**
- * Derive SKU, lineup, and series from the billing product name.
- * e.g. "COMP-RG1-8GB" → { sku: "COMP-RG1-8GB", lineup: "COMP", series: "RG1" }
- */
export function parseProductName(name: string): {
sku: string
lineup?: "BASE" | "COMP" | "GAME" | "ELITE"
@@ -273,18 +205,12 @@ export function parseProductName(name: string): {
export interface ObjectStorageSpecs {
storageGB?: number
- /** Raw value text for the storage bullet, e.g. "1,000 GB SSD-Cached Storage". */
storageValue?: string
storageType?: ParsedSpecs["storageType"]
- /** Access key/credential allowance, e.g. "Max 5 active credentials", "Unlimited". */
accessKeys?: string
- /** Monthly egress allowance, e.g. "1 TB Free (Overage just $0.01/GB)". */
egress?: string
- /** API request pricing/limits, e.g. "100% Free (Unlimited GET, PUT, LIST)". */
apiRequests?: string
- /** Auto-archive/lifecycle policy, e.g. "14 Days (Files transition automatically)". */
archivePolicy?: string
- /** Remaining plain-text bullets not matched to a labeled field above. */
features: string[]
}
@@ -297,11 +223,6 @@ const OBJECT_STORAGE_LABELS: Record = {
"auto-archive threshold": "archivePolicy",
}
-/**
- * Parses object storage plan bullets, which follow a "Label: value" pattern
- * for the structured fields (Storage Limit, Access Keys, Monthly Egress, API
- * Requests, Auto-Archive Threshold) followed by plain marketing bullets.
- */
export function parseObjectStorageSpecs(html: string | null): ObjectStorageSpecs {
const result: ObjectStorageSpecs = { features: [] }
if (!html) return result
diff --git a/packages/core/products/billing-service.ts b/packages/core/products/billing-service.ts
index bd080fa..53948b5 100644
--- a/packages/core/products/billing-service.ts
+++ b/packages/core/products/billing-service.ts
@@ -52,6 +52,7 @@ export async function getGamePlans(categorySlug: string): Promise
ramGB: number
- /** RAM generation if the description names one, e.g. "DDR4" — omitted when unspecified */
ramType?: string
storageGB: number
- /** Human-friendly storage type, e.g. "NVMe SSD Storage" or "Storage Array" when no drive type was named */
storageLabel?: string
bandwidth: { amount: number; unit: "MB" | "GB" | "TB" } | null
uplink?: { amount: number; unit: "Mbps" | "Gbps" }
@@ -43,8 +20,6 @@ export interface GamePlanSpec {
location?: string
popular?: boolean
url?: string
- /** Availability status. Defaults to "in_stock" when omitted. */
stock?: "in_stock" | "out_of_stock" | "coming_soon"
- /** Native billing prices per currency code, e.g. { GBP: 4, EUR: 4.59, USD: 5.37 } */
prices?: Record
}
\ No newline at end of file
diff --git a/packages/ui/components/Layouts/Games/game-hub.tsx b/packages/ui/components/Layouts/Games/game-hub.tsx
index 6762fbd..d999d82 100644
--- a/packages/ui/components/Layouts/Games/game-hub.tsx
+++ b/packages/ui/components/Layouts/Games/game-hub.tsx
@@ -5,6 +5,7 @@ import {
Server,
Search,
MemoryStick,
+ Cpu,
HardDrive,
Network,
Shield,
@@ -48,6 +49,7 @@ function PlanCard({ plan }: { plan: GamePlanSpec }) {
outOfStock={plan.stock === "out_of_stock"}
url={plan.url}
specs={[
+ ...(plan.cpu ? [{ icon: Cpu, value: `${plan.cpu}`, label: plan.cpu === 1 ? "vCPU" : "vCPUs" }] : []),
{ icon: MemoryStick, value: `${plan.ramGB} GB`, label: plan.ramType ? `${plan.ramType} RAM` : "RAM" },
{ icon: HardDrive, value: `${plan.storageGB} GB`, label: plan.storageLabel ?? "Storage" },
{ icon: Network, value: formatBandwidth(plan), label: "Bandwidth" },