@@ -33,23 +33,52 @@ function stripHtml(html: string): string {
3333 return html . replace ( / < [ ^ > ] * > / g, " " ) . replace ( / & a m p ; / g, "&" ) . replace ( / \s + / g, " " ) . trim ( )
3434}
3535
36- /** Split stripped text into individual bullet lines for more reliable matching. */
37- function bulletLines ( text : string ) : string [ ] {
36+ /**
37+ * Split into individual bullet lines. Paymenter descriptions are usually a
38+ * `<ul><li>` list (one bullet per `<li>`); older ones instead separate
39+ * bullets with a "•" character or bare newlines. Insert a line break at each
40+ * list-item/paragraph/`<br>` boundary before stripping tags so line-based
41+ * extraction below sees one bullet per line regardless of which format the
42+ * description actually uses.
43+ */
44+ function bulletLines ( html : string ) : string [ ] {
45+ const withBreaks = html
46+ . replace ( / < \/ ( l i | p | d i v | h [ 1 - 6 ] ) > / gi, "\n" )
47+ . replace ( / < b r \s * \/ ? > / gi, "\n" )
48+ const text = withBreaks
49+ . replace ( / < [ ^ > ] * > / g, " " )
50+ . replace ( / & a m p ; / g, "&" )
51+ . replace ( / [ \t ] + / g, " " )
3852 return text
3953 . split ( / [ • \n ] / )
4054 . map ( ( s ) => s . trim ( ) )
4155 . filter ( Boolean )
4256}
4357
58+ /** First "<number> GB|TB" in a line, converted to GB (TB × 1024). Returns undefined if the line has none. */
59+ function firstSizeGB ( line : string ) : number | undefined {
60+ const m = line . match ( / ( \d + ) \s * ( G B | T B ) \b / i)
61+ if ( ! m ) return undefined
62+ const amount = parseInt ( m [ 1 ] )
63+ return / t b / i. test ( m [ 2 ] ) ? amount * 1024 : amount
64+ }
65+
4466export function parseDescriptionSpecs ( html : string | null ) : ParsedSpecs {
4567 if ( ! html ) return { }
4668
4769 const text = stripHtml ( html )
48- const lines = bulletLines ( text )
70+ const lines = bulletLines ( html )
4971
5072 // ── CPU cores ──────────────────────────────────────────────────────────────
5173 // "1 Core of Ryzen 7 Power", "2 Cores of Ryzen 7", "2 Ampere® Altra® ARM64 Cores"
52- // Fallback: "8 cores and 16 threads", "Octa-Core", "Quad-Core"
74+ // "ELITE Resource Allocation: 8 Dedicated/Pinned Physical Cores and 16 threads"
75+ // Fallback: "8 cores and 16 threads", "Octa-Core", "Quad-Core", "2 vCPU"
76+ //
77+ // Deliberately NOT a "grab any number near the word core/cpu" fallback —
78+ // descriptions also state the CPU *model* number in the same breath
79+ // ("Ryzen™ 9 5900X", "Core Ultra 7 265"), and a wrong core count is worse
80+ // than none: it ships incorrect specs instead of just dropping the plan
81+ // (which surfaces via warnDroppedProduct so it gets fixed at the source).
5382 const NAMED_CORES : Record < string , number > = { mono : 1 , dual : 2 , quad : 4 , hexa : 6 , octa : 8 , deca : 10 , dodeca : 12 }
5483 let cpu : number | undefined
5584 for ( const line of lines ) {
@@ -60,9 +89,18 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs {
6089 }
6190 }
6291 if ( ! cpu ) {
63- // "8 cores and 16 threads" — number before "cores" anywhere in text
64- const m = text . match ( / \b ( \d + ) \s + [ C c ] o r e s ? \b / )
65- if ( m ) cpu = parseInt ( m [ 1 ] )
92+ // Number and "Cores" in the same bullet but not at the start of it — e.g.
93+ // "ELITE Resource Allocation: 8 Dedicated/Pinned Physical Cores and 16
94+ // threads". Allow up to 4 filler words between the number and "Cores",
95+ // bounded per-line so it can't reach into an unrelated bullet.
96+ // Requires the plural "Cores", not "Core" — singular "Core" shows up in
97+ // non-count marketing phrases too ("Ryzen™ 9 5900X Core Processing",
98+ // "Core Ultra 7 265"), where the preceding number is a CPU model number,
99+ // not a core count, and guessing wrong is worse than leaving it unset.
100+ for ( const line of lines ) {
101+ const m = line . match ( / ( \d + ) \s + (?: [ \w / . - ] + \s + ) { 0 , 4 } ?[ C c ] o r e s \b / )
102+ if ( m ) { cpu = parseInt ( m [ 1 ] ) ; break }
103+ }
66104 }
67105 if ( ! cpu ) {
68106 // "Octa-Core", "Quad-Core" etc
@@ -77,47 +115,39 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs {
77115 }
78116
79117 // ── RAM ────────────────────────────────────────────────────────────────────
80- // "2 GB DDR4 RAM", "4 GB ECC RAM", "8GB DDR4 RAM", "1 GB RAM"
81- // "4 GB High-Speed DDR4 RAM" — hyphenated adjectives allowed between the size and "RAM"
82- const ramMatch = text . match ( / ( \d + ) \s * G B \s + (?: [ \w - ] + \s + ) * ?R A M \b / i)
83- const ramGB = ramMatch ? parseInt ( ramMatch [ 1 ] ) : undefined
84- const ramTypeMatch = ramMatch ? ramMatch [ 0 ] . match ( / D D R \s ? ( [ 3 4 5 ] ) / i) : null
118+ // Any bullet mentioning "RAM" or "memory" is classified as the RAM line;
119+ // the first GB figure in that line is the amount, wherever it sits.
120+ // "2 GB DDR4 RAM", "4 GB ECC RAM", "RAM: 16GB of fast memory"
121+ const ramLine = lines . find ( ( line ) => / \b r a m \b | \b m e m o r y \b / i. test ( line ) )
122+ const ramGB = ramLine ? firstSizeGB ( ramLine ) : undefined
123+ const ramTypeMatch = ramLine ? ramLine . match ( / D D R \s ? ( [ 3 4 5 ] ) / i) : null
85124 const ramType = ramTypeMatch ? `DDR${ ramTypeMatch [ 1 ] } ` : undefined
86125
87126 // ── Storage ────────────────────────────────────────────────────────────────
88- // "25 GB SSD", "40 GB NVMe SSD", "100 GB SSD Storage", "40GB Disk Storage"
89- // "80 GB Local NVMe Storage" (no SSD/HDD/Disk keyword)
90- // Also handles TB drives: "2 x 1 TB NVMe SSD", "4 x 16 TB SATA HDD"
91- const storageMatchGB = text . match ( / ( \d + ) \s * G B \s + (?: L o c a l \s + ) ? (?: N V M e \s + ) ? (?: S S D | D i s k | H D D | S t o r a g e ) \b / i)
92- const storageMatchTB = ! storageMatchGB
93- ? text . match ( / ( \d + ) \s * T B \s + (?: N V M e \s + | E n t e r p r i s e \s + | S A T A \s + ) ? (?: S S D | H D D | D i s k ) / i)
94- : null
95- const storageGB = storageMatchGB
96- ? parseInt ( storageMatchGB [ 1 ] )
97- : storageMatchTB
98- ? parseInt ( storageMatchTB [ 1 ] ) * 1024
99- : undefined
100-
101- const storageMatchText = storageMatchGB ?. [ 0 ] ?? storageMatchTB ?. [ 0 ]
102- const storageType : ParsedSpecs [ "storageType" ] = storageMatchText
103- ? / n v m e / i. test ( storageMatchText )
127+ // Any bullet mentioning a storage-ish keyword is classified as the storage
128+ // line; the first GB/TB figure in it is the amount. Tolerates arbitrary
129+ // wording/ordering — "80 GB Local NVMe Storage", "Storage: 80GB of blazing
130+ // SSD space", "2 x 1 TB NVMe SSD (RAID 1)" all resolve the same way.
131+ const storageLine = lines . find ( ( line ) => / \b (?: s s d | h d d | n v m e | s t o r a g e | d i s k | d r i v e ) \b / i. test ( line ) )
132+ const storageGB = storageLine ? firstSizeGB ( storageLine ) : undefined
133+
134+ const storageType : ParsedSpecs [ "storageType" ] = storageLine
135+ ? / n v m e / i. test ( storageLine )
104136 ? "nvme"
105- : / s s d / i. test ( storageMatchText )
137+ : / s s d / i. test ( storageLine )
106138 ? "ssd"
107- : / h d d / i. test ( storageMatchText )
139+ : / h d d / i. test ( storageLine )
108140 ? "hdd"
109141 : "generic"
110142 : undefined
111143
112144 // Raw storage label for multi-drive dedicated configs
113- let storageDescription : string | undefined
114- for ( const line of lines ) {
115- if ( / \b (?: N V M e | S S D | H D D ) \b / i. test ( line ) ) {
116- const beforeColon = line . split ( ':' ) [ 0 ] . trim ( )
117- if ( beforeColon . length > 4 && beforeColon . length < 80 ) storageDescription = beforeColon
118- break
119- }
120- }
145+ const storageDescription = storageLine
146+ ? ( ( ) => {
147+ const beforeColon = storageLine . split ( ':' ) [ 0 ] . trim ( )
148+ return beforeColon . length > 4 && beforeColon . length < 80 ? beforeColon : undefined
149+ } ) ( )
150+ : undefined
121151
122152 // ── Uplink ─────────────────────────────────────────────────────────────────
123153 // "1 Gbps Network Port", "4 Gbps"
@@ -146,7 +176,9 @@ export function parseDescriptionSpecs(html: string | null): ParsedSpecs {
146176 let cpuModel : string | undefined
147177 let hardware : ParsedSpecs [ "hardware" ]
148178
149- const ryzenMatch = text . match ( / A M D \s + R y z e n [ ™ ™ ] ? \s + \d + (?: \s + (?: P R O \s + ) ? \d + \w * ) ? / i)
179+ // "AMD Ryzen™ 9 5900X", "Ryzen 3700X" (single model token, no series digit,
180+ // "AMD" prefix not always stated — "Ryzen" alone is unambiguously AMD)
181+ const ryzenMatch = text . match ( / (?: A M D \s + ) ? R y z e n [ ™ ™ ] ? \s + (?: \d + \s + ) ? (?: P R O \s + ) ? \d + \w * / i)
150182 const ampereMatch = text . match ( / A m p e r e [ ® ® ] ? \s + A l t r a [ ® ® ] ? (?: \s + A R M 6 4 ) ? / i)
151183 // Intel: Xeon, Core Ultra, Core i-series
152184 const intelMatch = text . match ( / I n t e l [ ® ® ] ? \s + (?: C o r e [ ™ ™ ] ? \s + U l t r a \s + \d + (?: \s + \d + ) ? | C o r e [ ™ ™ ] ? \s + i \d + [ - ] \d + \w * | X e o n [ ® ® ] ? (?: \s + \w + ) * ) / i)
0 commit comments