Skip to content

Commit d8d0f27

Browse files
committedDec 26, 2022
#1 Обновление parseTitle, добавлены тесты, небольшой рефакторинг
1 parent a837dcc commit d8d0f27

File tree

8 files changed

+286
-147
lines changed

8 files changed

+286
-147
lines changed
 

‎jest.config.js

+1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
module.exports = {
33
preset: 'ts-jest',
44
testEnvironment: 'jsdom',
5+
setupFilesAfterEnv: ["jest-expect-message"]
56
};

‎package-lock.json

+13-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+7
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"husky": "^7.0.0",
3232
"jest": "^29.2.1",
3333
"jest-environment-jsdom": "^29.2.1",
34+
"jest-expect-message": "^1.1.3",
3435
"patch-package": "^6.4.7",
3536
"path-browserify": "^1.0.1",
3637
"prettier": "^2.4.1",
@@ -119,6 +120,12 @@
119120
{
120121
"code": 100
121122
}
123+
],
124+
"jest/valid-expect": [
125+
"error",
126+
{
127+
"maxArgs": 2
128+
}
122129
]
123130
}
124131
}
+198-102
Original file line numberDiff line numberDiff line change
@@ -1,148 +1,244 @@
1-
import {parseTitle} from './parseTitle';
1+
import {parseTitle, parseTitleWithPrice} from './parseTitle';
22

33
describe('weight', () => {
4-
test('Extract weight', () => {
4+
test('Extract weight/volume', () => {
55
expect(parseTitle('Garnier, 1,5кг')).toStrictEqual({
6-
weight: 1.5,
76
quantity: 1,
8-
item_weight: 1.5,
9-
weight_unit: 'кг',
7+
units: [{
8+
total: 1.5,
9+
value: 1.5,
10+
unit: 'кг',
11+
}],
1012
});
1113

1214
expect(parseTitle('Сметана Пискаревская, 15%, 200 г')).toStrictEqual({
13-
weight: 0.2,
1415
quantity: 1,
15-
item_weight: 0.2,
16-
weight_unit: 'кг',
17-
});
18-
expect(
19-
parseTitle('Творог фруктовый Агуша Черника 3.9% 100г для дет.пит. с 6 месяцев'),
20-
).toStrictEqual({
21-
weight: 0.1,
22-
quantity: 1,
23-
item_weight: 0.1,
24-
weight_unit: 'кг',
16+
units: [{
17+
total: .2,
18+
value: .2,
19+
unit: 'кг',
20+
}],
2521
});
2622

2723
expect(parseTitle('Garnier Банан для очень сухих волос, 390 мл')).toStrictEqual({
28-
weight: 0.39,
2924
quantity: 1,
30-
item_weight: 0.39,
31-
weight_unit: 'л',
25+
units: [{
26+
total: .39,
27+
value: .39,
28+
unit: 'л',
29+
}],
3230
});
3331
expect(parseTitle('Garnier, 390 грамм')).toStrictEqual({
34-
weight: 0.39,
3532
quantity: 1,
36-
item_weight: 0.39,
37-
weight_unit: 'кг',
33+
units: [{
34+
total: .39,
35+
value: .39,
36+
unit: 'кг',
37+
}],
3838
});
3939
});
40+
4041
test('extract length', () => {
41-
expect(parseTitle('Силовой кабель МБ Провод ВВГмб-П нг(А)-LS 3 x 1,5 мм², 10 м')).toStrictEqual(
42-
{
43-
weight: 10,
44-
quantity: 1,
45-
item_weight: 10,
46-
weight_unit: 'м',
47-
},
48-
);
42+
expect(parseTitle('Силовой кабель МБ Провод ВВГмб-П нг(А)-LS 3 x 1,5 мм², 10 м'))
43+
.toStrictEqual({
44+
quantity: 1,
45+
units: [{
46+
total: 10,
47+
value: 10,
48+
unit: 'м',
49+
}],
50+
},
51+
);
52+
});
53+
test('without unit', () => {
54+
expect(parseTitle('Aroy-d 70% 17-19%')).toStrictEqual({quantity: 1, units: []});
4955
});
5056
});
5157
test('Extract quantity', () => {
52-
expect(parseTitle('Aroy-d Кокосовое молоко 70% жирность 17-19%, 2 шт')).toStrictEqual({
53-
weight: null,
58+
expect(parseTitle('Aroy-d 70% жирность 17-19%, 2 шт')).toStrictEqual({
5459
quantity: 2,
55-
item_weight: null,
56-
weight_unit: null,
60+
units: [],
5761
});
5862
});
5963

60-
test('Extract quantity and weight', () => {
61-
expect(parseTitle('Щедрые хлебцы с чесноком 100г/8шт')).toStrictEqual({
62-
weight: 0.1 * 8,
63-
quantity: 8,
64-
item_weight: 0.1,
65-
weight_unit: 'кг',
66-
});
67-
expect(parseTitle('Рис Увелка пропаренный, 5×80 г')).toStrictEqual({
68-
weight: 0.08 * 5,
69-
quantity: 5,
70-
item_weight: 0.08,
71-
weight_unit: 'кг',
72-
});
73-
expect(parseTitle("Кофе молотый 500 г, Peppo's набор 2 упаковки по 250 гр")).toStrictEqual({
74-
weight: 0.5,
75-
quantity: 2,
76-
item_weight: 0.25,
77-
weight_unit: 'кг',
64+
describe('Extract quantity and weight', () => {
65+
test('Common cases', () => {
66+
expect(parseTitle('Щедрые хлебцы с чесноком 100г/8шт')).toStrictEqual({
67+
quantity: 8,
68+
units: [{
69+
total: .1 * 8,
70+
value: .1,
71+
unit: 'кг',
72+
}],
73+
});
7874
});
79-
expect(parseTitle('Кофе молотый, 1 кг, натуральный (2 упаковки по 500г)')).toStrictEqual({
80-
weight: 1,
81-
quantity: 2,
82-
item_weight: 0.5,
83-
weight_unit: 'кг',
75+
test('cases', () => {
76+
const cases = [
77+
'80г×5шт',
78+
'80 г. по 5',
79+
'5×80 г',
80+
'5х80 г',
81+
'80 г x 5 шт',
82+
'5 шт. по 80 грамм',
83+
'80 г x 5',
84+
];
85+
for (const title of cases) {
86+
expect(parseTitle('Рис, ' + title), title).toStrictEqual({
87+
quantity: 5,
88+
units: [{total: .08 * 5, value: .08, unit: 'кг'}],
89+
});
90+
}
8491
});
8592

86-
expect(
87-
parseTitle('Пряность Куркума молотая для мяса, риса, овощей Global Spice - набор 3х20 г'),
88-
).toStrictEqual({
89-
weight: 0.02 * 3,
90-
quantity: 3,
91-
item_weight: 0.02,
92-
weight_unit: 'кг',
93-
});
93+
test('Fuzzy check combinations', () => {
94+
const quantity = 5;
95+
const quantity_units = ['шт'];
96+
const weight = 100;
97+
const weight_units = {
98+
'кг': ['г', 'грамм', 'гр'],
99+
'л': ['мл'],
100+
};
101+
const delimiter = ['по', '×', 'х', '*'];
102+
for (const q_u of quantity_units) {
103+
for (const [unit, unit_displays] of Object.entries(weight_units)) {
104+
for (const u_d of unit_displays) {
105+
for (const d of delimiter) {
94106

95-
expect(parseTitle('Aroy-d Кокосовое молоко 70% жирность 17-19%, 500 мл x 2 шт')).toStrictEqual({
96-
weight: 1.0,
97-
quantity: 2,
98-
item_weight: 0.5,
99-
weight_unit: 'л',
100-
});
107+
const weight_variations = [`${weight}${u_d}`, `${weight} ${u_d}`, `${weight} ${u_d}.`];
108+
const quantity_variations = [`${quantity}${q_u}`, `${quantity} ${q_u}`,
109+
`${quantity} ${q_u}.`, `${quantity}`];
110+
let delim_variations = [d, ` ${d}`, `${d} `, ` ${d} `, ` ${d} `];
111+
if (['по'].includes(d)) {
112+
// Word delim must be around spaces
113+
delim_variations = [` ${d} `]
114+
}
101115

102-
expect(parseTitle('100% Кокосовое молоко АЗБУКА ПРОДУКТОВ кулинарное 6шт*1л')).toStrictEqual({
103-
weight: 6.0,
104-
quantity: 6,
105-
item_weight: 1.0,
106-
weight_unit: 'л',
116+
for (const w of weight_variations) {
117+
for (const q of quantity_variations) {
118+
for (const dv of delim_variations) {
119+
const titles = [`${w}${dv}${q}`, `${q}${dv}${w}`];
120+
for (const t of titles) {
121+
expect(parseTitle('Рис, ' + t), t).toStrictEqual({
122+
quantity: quantity,
123+
units: [{total: (weight * quantity) / 1000, value: weight / 1000, unit: unit}],
124+
});
125+
}
126+
}
127+
}
128+
}
129+
}
130+
}
131+
}
132+
}
107133
});
108-
109-
expect(
110-
parseTitle('Тофу классический, соевый продукт, комплект 2 шт. по 300 грамм, Green East'),
111-
).toStrictEqual({
112-
weight: 0.6,
134+
});
135+
test('Priority parse weight with quantity', () => {
136+
expect(parseTitle('Кофе молотый 500 г, Peppo\'s набор 2 упаковки по 250 гр')).toStrictEqual({
113137
quantity: 2,
114-
item_weight: 0.3,
115-
weight_unit: 'кг',
138+
units: [{
139+
total: .25 * 2,
140+
value: .25,
141+
unit: 'кг',
142+
}],
116143
});
117-
118-
expect(parseTitle('Влажный корм для кошек Whiskas, 75 г x 28')).toStrictEqual({
119-
weight: 0.075 * 28,
120-
quantity: 28,
121-
item_weight: 0.075,
122-
weight_unit: 'кг',
144+
expect(parseTitle('Кофе молотый, 1 кг, натуральный (2 упаковки по 500г)')).toStrictEqual({
145+
quantity: 2,
146+
units: [{
147+
total: .5 * 2,
148+
value: .5,
149+
unit: 'кг',
150+
}],
123151
});
124-
});
125-
126-
test('Extract quantity and weight with priority', () => {
127152
expect(parseTitle('Порционный сахар в стиках 1 кг (200шт. х 5 гр.) белый')).toStrictEqual({
128-
weight: 0.005 * 200,
129153
quantity: 200,
130-
item_weight: 0.005,
131-
weight_unit: 'кг',
154+
units: [{
155+
total: .005 * 200,
156+
value: .005,
157+
unit: 'кг',
158+
}],
132159
});
133160
});
134-
135161
test('Extract combined quantity', () => {
136162
expect(parseTitle('60шт х 10уп')).toStrictEqual({
137-
weight: null,
138163
quantity: 600,
139-
item_weight: null,
140-
weight_unit: null,
164+
units: [],
141165
});
142166
expect(parseTitle('SYNERGETIC 110 шт, набор 2х55 шт, бесфосфатные')).toStrictEqual({
143-
weight: null,
144167
quantity: 110,
145-
item_weight: null,
146-
weight_unit: null,
168+
units: [],
169+
});
170+
});
171+
172+
173+
describe('Parse with price', () => {
174+
test('with quantity', () => {
175+
expect(parseTitleWithPrice('Aroy-d 70% жирность 17-19%, 2 шт', 200))
176+
.toStrictEqual({
177+
quantity: 2,
178+
quantity_price: 100,
179+
quantity_price_display: '100 ₽/шт',
180+
units: [],
181+
});
182+
});
183+
test('with unit', () => {
184+
expect(parseTitleWithPrice('Aroy-d 70% жирность 17-19%, 200г', 200))
185+
.toStrictEqual({
186+
quantity: 1,
187+
quantity_price: null,
188+
quantity_price_display: null,
189+
units: [{
190+
total: .2,
191+
value: .2,
192+
price: 200 / .2,
193+
price_display: '1000 ₽/кг',
194+
unit: 'кг',
195+
}],
196+
});
197+
});
198+
test('without unit', () => {
199+
expect(parseTitleWithPrice('Aroy-d 70% 17-19%', 200))
200+
.toStrictEqual(null);
201+
});
202+
203+
test('With quantity and unit', () => {
204+
expect(parseTitleWithPrice('Aroy-d 70% 17-19% 500 мл x 2 шт', 200))
205+
.toStrictEqual({
206+
'quantity': 2,
207+
'quantity_price': 100,
208+
'quantity_price_display': '100 ₽/шт',
209+
'units': [
210+
{
211+
'price': 200,
212+
'price_display': '200 ₽/л',
213+
'total': 1,
214+
'unit': 'л',
215+
'value': 0.5,
216+
},
217+
],
218+
});
219+
});
220+
});
221+
222+
223+
describe('Multi-units', () => {
224+
test('Parse led lamps, we need compare lm/w and lm/rub', () => {
225+
expect(parseTitle('Лампа светодиодная E27 220-240 В 10 Вт ' +
226+
'груша матовая 1000 лм нейтральный белый свет')).toStrictEqual(
227+
{
228+
quantity: 1,
229+
units: [
230+
{
231+
value: 1000,
232+
unit: 'лм',
233+
total: 1000,
234+
},
235+
{
236+
value: 10,
237+
unit: 'Вт',
238+
total: 10,
239+
},
240+
],
241+
},
242+
);
147243
});
148244
});

‎src/best_price/common/parseTitle.ts

+52-30
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,28 @@
11
import {mRegExp, round} from '../../utils';
22

3-
export type ParseTitleResult = {
4-
weight: number | null;
3+
export type Unit = 'кг' | 'л' | 'м'
4+
5+
export interface UnitValue {
6+
unit: Unit,
7+
value: number, // Per item value
8+
total: number, // Per quantity summary,
9+
}
10+
11+
export interface ParseTitleResult {
512
quantity: number;
6-
item_weight: number | null;
7-
weight_unit: 'кг' | 'л' | 'м' | null;
8-
};
13+
units: UnitValue[];
14+
}
15+
916
// const WORD_BOUNDARY_BEGIN = /(?:^|\s)/
10-
const WORD_BOUNDARY_END = /(?=\s|[.,);/]|$)/;
17+
const WORD_BOUNDARY_END = /(?=\s*|[.,);/]|$)/;
1118
const WEIGHT_REGEXP = mRegExp([
1219
/(?<value>\d+[,.]\d+|\d+)/, // Value
1320
/\s?/, // Space
1421
'(?<unit>',
1522
'(?<weight_unit>(?<weight_SI>кг|килограмм(?:ов|а|))|г|грамм(?:ов|а|)|гр)',
1623
'|(?<volume_unit>(?<volume_SI>л|литр(?:ов|а|))|мл)',
1724
'|(?<length_unit>(?<length_SI>м|метр(?:ов|а|)))',
18-
')',
25+
')\\.?',
1926
WORD_BOUNDARY_END,
2027
]);
2128

@@ -38,7 +45,7 @@ const QUANTITY_2_REGEXP = RegExp(
3845
`(?<quantity_2>\\d+)\\s?(?<quantity_2_unit>${QUANTITY_UNITS.join('|')})\\.?`,
3946
);
4047

41-
const COMBINE_DELIMETER_REGEXP = /\s?(?:[xх*×/]|по)\s?/;
48+
const COMBINE_DELIMETER_REGEXP = /\s*?(?:[xх*×/]|по)\s*?/;
4249
const COMBINE_QUANTITY_LIST = [
4350
mRegExp([/(?<quantity_2>\d+)/, COMBINE_DELIMETER_REGEXP, QUANTITY_REGEXP]), // 20x100шт
4451
mRegExp([QUANTITY_REGEXP, COMBINE_DELIMETER_REGEXP, /(?<quantity_2>\d+)/]), // 20уп*100
@@ -66,40 +73,44 @@ interface MatchGroupsResult {
6673

6774
function parseGroups(groups: MatchGroupsResult): ParseTitleResult {
6875
const result: ParseTitleResult = {
69-
weight: null,
70-
item_weight: null,
71-
weight_unit: null,
7276
quantity: 1,
77+
units: [],
7378
};
7479

7580
if (groups.value) {
7681
const valueStr: string | undefined = groups?.value;
7782
const unit = groups?.unit;
7883
if (valueStr && unit) {
7984
let value = parseFloat(valueStr.replace(',', '.'));
85+
let unit: Unit | null = null;
8086
// Всегда считаем в мл и г
8187
if (groups.weight_unit) {
8288
if (!groups.weight_SI) {
8389
value /= 1000;
8490
}
85-
result.weight_unit = 'кг';
91+
unit = 'кг'
8692
}
8793
if (groups.volume_unit) {
8894
if (!groups.volume_SI) {
8995
value /= 1000;
9096
}
91-
result.weight_unit = 'л';
97+
unit = 'л'
9298
}
93-
9499
if (groups.length_unit) {
95100
if (!groups.length_SI) {
96101
value /= 1000;
97102
}
98-
result.weight_unit = 'м';
103+
unit = 'м';
104+
}
105+
if (! unit) {
106+
throw "Unknown unit"
99107
}
100108

101-
result.weight = value;
102-
result.item_weight = value;
109+
result.units.push({
110+
unit,
111+
value,
112+
total: value,
113+
});
103114
}
104115
}
105116

@@ -109,11 +120,11 @@ function parseGroups(groups: MatchGroupsResult): ParseTitleResult {
109120
result.quantity = parseInt(valueStr);
110121
}
111122
}
112-
113-
if (result.item_weight && result.quantity > 1) {
114-
result.weight = result.quantity * result.item_weight;
123+
if (result.quantity > 1) {
124+
for (const u of result.units) {
125+
u.total = result.quantity * u.value;
126+
}
115127
}
116-
117128
return result;
118129
}
119130

@@ -153,27 +164,38 @@ export function parseTitle(title: string): ParseTitleResult {
153164
return parseGroups(groups as MatchGroupsResult);
154165
}
155166

167+
168+
export interface UnitPriceValue extends UnitValue {
169+
price: number,
170+
price_display: string
171+
}
172+
156173
export interface ParseTitlePriceResult extends ParseTitleResult {
157-
weight_price: number | null;
158-
weight_price_display: string | null;
174+
units: UnitPriceValue[]
159175
quantity_price: number | null;
160176
quantity_price_display: string | null;
161177
}
162178

163179
export function parseTitleWithPrice(title: string, price: number): ParseTitlePriceResult | null {
180+
181+
const {units, ...titleParsed} = parseTitle(title)
164182
const res: ParseTitlePriceResult = {
165-
...parseTitle(title),
166-
weight_price: null,
167-
weight_price_display: null,
183+
...titleParsed,
184+
units: [],
168185
quantity_price: null,
169186
quantity_price_display: null,
170187
};
171-
if ((!res.quantity || res.quantity == 1) && !res.weight) {
188+
189+
if ((!res.quantity || res.quantity == 1) && !units.length) {
172190
return null;
173191
}
174-
if (res.weight) {
175-
res.weight_price = round(price / res.weight);
176-
res.weight_price_display = `${res.weight_price} ₽/${res.weight_unit || '?'}`;
192+
for (const u of units) {
193+
const p = round(price / u.total)
194+
res.units.push({
195+
...u,
196+
price: p,
197+
price_display: `${p} ₽/${u.unit || '?'}`
198+
})
177199
}
178200
if (res.quantity > 1) {
179201
res.quantity_price = round(price / res.quantity);

‎src/best_price/common/price_render.ts

+4-8
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,10 @@ export function renderBestPrice(titleInfo: ParseTitlePriceResult | null): HTMLEl
77
if (!titleInfo) {
88
return wrapEl;
99
}
10-
if (titleInfo.weight_price_display) {
11-
const weightEl = document.createElement('p');
12-
// price -> weight
13-
// x -> 1000г
14-
// TODO unit size
15-
weightEl.innerText = titleInfo.weight_price_display;
16-
wrapEl.appendChild(weightEl);
10+
for (const u of titleInfo.units) {
11+
const el = document.createElement('p');
12+
el.innerText = u.price_display;
13+
wrapEl.appendChild(el);
1714
}
1815
if (titleInfo.quantity_price_display) {
1916
const qtyEl = document.createElement('p');
@@ -24,7 +21,6 @@ export function renderBestPrice(titleInfo: ParseTitlePriceResult | null): HTMLEl
2421
wrapEl.style.border = '1px solid red';
2522
wrapEl.style.padding = '5px';
2623
wrapEl.style.margin = '5px';
27-
2824
wrapEl.style.width = 'fit-content';
2925
}
3026
return wrapEl;

‎src/utils/dom.ts

+10-6
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,32 @@
11
import {Optional} from '@app/utils/types';
22

3-
export function getElementByXpath(xpath: string, root: Node = document): HTMLElement | null {
3+
4+
export function getElementByXpath<T extends Node = HTMLElement>(xpath: string,
5+
root: Node = document): T | null {
46
const e = document.evaluate(
57
xpath,
68
root,
79
null,
810
XPathResult.FIRST_ORDERED_NODE_TYPE,
911
null,
1012
).singleNodeValue;
11-
return e && (e as HTMLElement);
13+
return e && (e as T);
1214
}
1315

14-
export function getElementsByXpath(xpath: string, root: Node = document): HTMLElement[] {
16+
export function getElementsByXpath<T extends Node = HTMLElement>(
17+
xpath: string,
18+
root: Node = document): T[] {
1519
const iterator = document.evaluate(
1620
xpath,
1721
root,
1822
null,
1923
XPathResult.ORDERED_NODE_ITERATOR_TYPE,
2024
null,
2125
);
22-
const result: HTMLElement[] = [];
26+
const result: T[] = [];
2327
let el = iterator.iterateNext();
2428
while (el) {
25-
result.push(el as HTMLElement);
29+
result.push(el as T);
2630
el = iterator.iterateNext();
2731
}
2832
return result;
@@ -32,7 +36,7 @@ export function markElementHandled(
3236
wrapFn: (el: HTMLElement) => void,
3337
attrName = '_handled',
3438
): (el: HTMLElement) => void {
35-
return function (el) {
39+
return function(el) {
3640
if (el.getAttribute(attrName)) {
3741
return;
3842
}

‎tsconfig.json

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"files": ["node_modules/jest-expect-message/types/index.d.ts"],
23
"compilerOptions": {
34
"target": "es2017",
45
"module": "esnext",

0 commit comments

Comments
 (0)
Please sign in to comment.