From edb450d4e667d649250c712dd243a6f953ad8768 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Sun, 25 Oct 2026 12:04:55 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20parse=20abbreviated=20counts=20like=20"?= =?UTF-8?q?59=20=D0=BC=D0=BB=D0=BD"=20and=20"571=20=D1=82=D1=8B=D1=81."?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/lib/numbers.js | 33 +++++++++++++++++++++++ test/numbers.test.js | 62 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/lib/numbers.js create mode 100644 test/numbers.test.js diff --git a/src/lib/numbers.js b/src/lib/numbers.js new file mode 100644 index 0000000..7534f0d --- /dev/null +++ b/src/lib/numbers.js @@ -0,0 +1,33 @@ +const UNITS = new Map([ + ['k', 1e3], ['тыс', 1e3], ['тысяча', 1e3], ['тысячи', 1e3], ['тысяч', 1e3], ['thousand', 1e3], + ['m', 1e6], ['млн', 1e6], ['миллион', 1e6], ['миллиона', 1e6], ['миллионов', 1e6], ['million', 1e6], + ['b', 1e9], ['млрд', 1e9], ['миллиард', 1e9], ['миллиарда', 1e9], ['миллиардов', 1e9], ['billion', 1e9], +]); + +// "просмотров", "подписчиков", "views", "subscribers" and their other forms at the end of the text. +const COUNT_WORDS = /\s*(просмотров|просмотра|просмотр|подписчиков|подписчика|подписчики|подписчик|views?|subscribers?)$/; +const GROUPED_INTEGER = /^\d{1,3}(?:[ ,.]\d{3})+$/; +const DECIMAL = /^\d+(?:[.,]\d+)?$/; + +// Reads "59 млн", "571 тыс.", "1.2M", "12 345" and the like; the whole text must be a count. +export function parseCount(text) { + if (typeof text !== 'string') return null; + const normalized = text.replace(/\s+/g, ' ').trim().toLowerCase().replace(COUNT_WORDS, '').trim(); + const match = /^(\d[\d ,.]*?)(?: ?([a-zа-яё]+)\.?)?$/.exec(normalized); + if (!match) return null; + const [, number, unit] = match; + + if (unit === undefined) { + if (/^\d+$/.test(number)) return Number(number); + return GROUPED_INTEGER.test(number) ? Number(number.replace(/\D/g, '')) : null; + } + const multiplier = UNITS.get(unit); + if (multiplier === undefined || !DECIMAL.test(number)) return null; + return Math.round(Number(number.replace(',', '.')) * multiplier); +} + +export function parseViews(text) { + if (typeof text !== 'string') return null; + if (/^\s*(no|нет) (views|просмотров)\s*$/i.test(text)) return 0; + return parseCount(text); +} diff --git a/test/numbers.test.js b/test/numbers.test.js new file mode 100644 index 0000000..648598b --- /dev/null +++ b/test/numbers.test.js @@ -0,0 +1,62 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseCount, parseViews } from '../src/lib/numbers.js'; + +test('parseCount understands Russian abbreviations', () => { + assert.equal(parseCount('59 млн'), 59000000); + assert.equal(parseCount('59 млн'), 59000000); // non-breaking space, as YouTube renders it + assert.equal(parseCount('571 тыс.'), 571000); + assert.equal(parseCount('1,8 млн'), 1800000); + assert.equal(parseCount('3,4 млрд'), 3400000000); + assert.equal(parseCount('123 тысячи'), 123000); + assert.equal(parseCount('1,8 миллиона'), 1800000); + assert.equal(parseCount('146 миллионов'), 146000000); +}); + +test('parseCount understands English abbreviations', () => { + assert.equal(parseCount('1.2M'), 1200000); + assert.equal(parseCount('1.2m'), 1200000); + assert.equal(parseCount('123K'), 123000); + assert.equal(parseCount('1.5B'), 1500000000); + assert.equal(parseCount('2.5 million'), 2500000); +}); + +test('parseCount reads plain and grouped integers', () => { + assert.equal(parseCount('37'), 37); + assert.equal(parseCount('0'), 0); + assert.equal(parseCount('12 345'), 12345); + assert.equal(parseCount('12 345'), 12345); + assert.equal(parseCount('1,234,567'), 1234567); + assert.equal(parseCount('1.234.567'), 1234567); +}); + +test('parseCount ignores trailing "views" and "subscribers" words', () => { + assert.equal(parseCount('59 миллионов просмотров'), 59000000); + assert.equal(parseCount('1 просмотр'), 1); + assert.equal(parseCount('571 тыс. подписчиков'), 571000); + assert.equal(parseCount('1.2M views'), 1200000); + assert.equal(parseCount('1.2M subscribers'), 1200000); +}); + +test('parseCount rejects anything that is not wholly a count', () => { + for (const text of ['', 'Wun Wun', '10 л. назад', '2 г. назад', '7 дн. назад', '3 нед. назад', '1,2 тыс. зрителей', '5 Minute Crafts', 'abc', '1.2', '1,5']) { + assert.equal(parseCount(text), null, text); + } + for (const value of [null, undefined, 123, {}]) { + assert.equal(parseCount(value), null, String(value)); + } +}); + +test('parseViews adds the "no views" cases', () => { + assert.equal(parseViews('Нет просмотров'), 0); + assert.equal(parseViews('No views'), 0); + assert.equal(parseViews('59 млн'), 59000000); + assert.equal(parseViews('1,8 млн просмотров'), 1800000); + assert.equal(parseViews('1.2M views'), 1200000); +}); + +test('parseViews rejects dates and names', () => { + for (const text of ['10 л. назад', 'Wun Wun', '2 years ago', null, undefined]) { + assert.equal(parseViews(text), null, String(text)); + } +});