souqnorm 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- souqnorm-0.1.0/LICENSE +21 -0
- souqnorm-0.1.0/PKG-INFO +112 -0
- souqnorm-0.1.0/README.md +93 -0
- souqnorm-0.1.0/pyproject.toml +37 -0
- souqnorm-0.1.0/setup.cfg +4 -0
- souqnorm-0.1.0/src/souqnorm/__init__.py +8 -0
- souqnorm-0.1.0/src/souqnorm/arabic.py +74 -0
- souqnorm-0.1.0/src/souqnorm/cli.py +38 -0
- souqnorm-0.1.0/src/souqnorm/pipeline.py +120 -0
- souqnorm-0.1.0/src/souqnorm/units.py +104 -0
- souqnorm-0.1.0/src/souqnorm.egg-info/PKG-INFO +112 -0
- souqnorm-0.1.0/src/souqnorm.egg-info/SOURCES.txt +15 -0
- souqnorm-0.1.0/src/souqnorm.egg-info/dependency_links.txt +1 -0
- souqnorm-0.1.0/src/souqnorm.egg-info/entry_points.txt +2 -0
- souqnorm-0.1.0/src/souqnorm.egg-info/requires.txt +3 -0
- souqnorm-0.1.0/src/souqnorm.egg-info/top_level.txt +1 -0
- souqnorm-0.1.0/tests/test_souqnorm.py +136 -0
souqnorm-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Muhammad Tanzil
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
souqnorm-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: souqnorm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Normalization, size parsing and keyword expansion for Arabic/English e-commerce search queries
|
|
5
|
+
Author-email: Muhammad Tanzil <mtanzil666@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mtanzil6/souqnorm
|
|
8
|
+
Project-URL: Issues, https://github.com/mtanzil6/souqnorm/issues
|
|
9
|
+
Keywords: arabic,nlp,e-commerce,search,query normalization,information retrieval
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Natural Language :: Arabic
|
|
12
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# souqnorm
|
|
21
|
+
|
|
22
|
+
**Normalization, size parsing and keyword expansion for Arabic/English e-commerce search queries.**
|
|
23
|
+
|
|
24
|
+
Shoppers in Arabic-speaking markets type the same product many ways: `شامبو ٤٠٠مل`, `شامبو 400 ملل`,
|
|
25
|
+
`شاـــمبو 400ml`, `shampoo 13.5 fl oz`. Off-the-shelf search analyzers treat each of these as different
|
|
26
|
+
text. `souqnorm` is a small, dependency-free Python library that turns these forms into one consistent
|
|
27
|
+
representation before indexing or querying, whatever search engine you use.
|
|
28
|
+
|
|
29
|
+
## Features (v0.1)
|
|
30
|
+
|
|
31
|
+
- **Arabic orthographic normalization.** It unifies alef forms (أ إ آ ٱ → ا), maps ى → ي, ة → ه and ؤ/ئ → و/ي,
|
|
32
|
+
and strips diacritics (harakat) and tatweel. It also converts Arabic-Indic and Persian digits and the Arabic
|
|
33
|
+
decimal mark (٫), applies NFKC, lowercases Latin text and splits number/script boundaries
|
|
34
|
+
(`400مل` → `400 مل`). Every step can be switched off in `NormalizerConfig`.
|
|
35
|
+
- **Size and quantity parsing.** It recognizes Arabic and English unit spellings for volume (مل/ملل/مللي/ml, لتر/l,
|
|
36
|
+
oz/fl oz/أونصة), mass (جم/ج/غرام/g, كجم/kg, ملغ/mg, باوند/lb), counts (قطعة/حبة/كبسولة/قرص/منديل/كيس/pcs) and
|
|
37
|
+
length (ملم/سم/م/mm/cm/m), plus pack multipliers (`2×50 مل`, `10x6مل`). Battery capacity (`7000 ملي امبير`) is
|
|
38
|
+
not mistaken for volume. It converts each size to a
|
|
39
|
+
base unit (ml, g, pcs, mm) and gives it a unit-independent `match_key` (400 ml ≡ 13.5 fl oz ≡ 0.4 l).
|
|
40
|
+
- **Alternative keywords.** It builds a clean keyword list for query expansion. Numbers, unit words (e.g. `مل`),
|
|
41
|
+
special characters and empty strings are removed. Hyphenated words are both split and joined
|
|
42
|
+
(`anti-aging` → `anti`, `aging`, `antiaging`), and article-stripped forms are added (`للشعر` → `شعر`, `بالصبار` → `صبار`).
|
|
43
|
+
- **CLI and batch mode** with JSON / JSON Lines output.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install git+https://github.com/mtanzil6/souqnorm.git@v0.1.0
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
For development:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
git clone https://github.com/mtanzil6/souqnorm.git
|
|
55
|
+
cd souqnorm
|
|
56
|
+
pip install -e ".[dev]"
|
|
57
|
+
pytest
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from souqnorm import process
|
|
64
|
+
|
|
65
|
+
r = process("شامبـــو للشعر الجاف ٤٠٠مل")
|
|
66
|
+
r.normalized # 'شامبو للشعر الجاف 400 مل'
|
|
67
|
+
r.quantities[0].label # '400 ml'
|
|
68
|
+
r.quantities[0].match_key() # 'qty:400ml'
|
|
69
|
+
r.text_without_quantities # 'شامبو للشعر الجاف'
|
|
70
|
+
r.keywords # ['شامبو', 'للشعر', 'شعر', 'الجاف', 'جاف']
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
souqnorm "عطر ٣٫٤ fl oz"
|
|
75
|
+
souqnorm --file queries.txt > normalized.jsonl
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Custom configuration:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from souqnorm import NormalizerConfig, QueryNormalizer
|
|
82
|
+
qn = QueryNormalizer(NormalizerConfig(taa_marbuta_to_haa=False), strip_article=False)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Reproducing the evaluation
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
python benchmarks/build_benchmark.py # synthetic catalog + perturbed queries (seeded)
|
|
89
|
+
python benchmarks/run_benchmark.py # writes benchmarks/results/results.json
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
To evaluate on a real catalog (not distributed here), run:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pip install openpyxl
|
|
96
|
+
python benchmarks/real_titles.py path/to/titles.xlsx # writes benchmarks/results/real_titles.json
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The `build_benchmark.py` retrieval benchmark is **synthetic**. It uses fictional brands and template titles, and its queries are made by
|
|
100
|
+
applying common Arabic spelling variations. It shows how each normalization step affects results, but it is not a
|
|
101
|
+
substitute for evaluation on real query logs.
|
|
102
|
+
|
|
103
|
+
## Known limitations
|
|
104
|
+
|
|
105
|
+
- Article stripping is a heuristic and can wrongly shorten transliterated brand names that start with فال/بال
|
|
106
|
+
(it only adds variants and keeps the original keyword).
|
|
107
|
+
- `oz` is treated as fluid ounces (volume), the usual meaning in beauty retail.
|
|
108
|
+
- There is no spelling correction, stemming beyond articles, or Arabizi (Latin-script Arabic) transliteration yet.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT © 2026 Muhammad Tanzil
|
souqnorm-0.1.0/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# souqnorm
|
|
2
|
+
|
|
3
|
+
**Normalization, size parsing and keyword expansion for Arabic/English e-commerce search queries.**
|
|
4
|
+
|
|
5
|
+
Shoppers in Arabic-speaking markets type the same product many ways: `شامبو ٤٠٠مل`, `شامبو 400 ملل`,
|
|
6
|
+
`شاـــمبو 400ml`, `shampoo 13.5 fl oz`. Off-the-shelf search analyzers treat each of these as different
|
|
7
|
+
text. `souqnorm` is a small, dependency-free Python library that turns these forms into one consistent
|
|
8
|
+
representation before indexing or querying, whatever search engine you use.
|
|
9
|
+
|
|
10
|
+
## Features (v0.1)
|
|
11
|
+
|
|
12
|
+
- **Arabic orthographic normalization.** It unifies alef forms (أ إ آ ٱ → ا), maps ى → ي, ة → ه and ؤ/ئ → و/ي,
|
|
13
|
+
and strips diacritics (harakat) and tatweel. It also converts Arabic-Indic and Persian digits and the Arabic
|
|
14
|
+
decimal mark (٫), applies NFKC, lowercases Latin text and splits number/script boundaries
|
|
15
|
+
(`400مل` → `400 مل`). Every step can be switched off in `NormalizerConfig`.
|
|
16
|
+
- **Size and quantity parsing.** It recognizes Arabic and English unit spellings for volume (مل/ملل/مللي/ml, لتر/l,
|
|
17
|
+
oz/fl oz/أونصة), mass (جم/ج/غرام/g, كجم/kg, ملغ/mg, باوند/lb), counts (قطعة/حبة/كبسولة/قرص/منديل/كيس/pcs) and
|
|
18
|
+
length (ملم/سم/م/mm/cm/m), plus pack multipliers (`2×50 مل`, `10x6مل`). Battery capacity (`7000 ملي امبير`) is
|
|
19
|
+
not mistaken for volume. It converts each size to a
|
|
20
|
+
base unit (ml, g, pcs, mm) and gives it a unit-independent `match_key` (400 ml ≡ 13.5 fl oz ≡ 0.4 l).
|
|
21
|
+
- **Alternative keywords.** It builds a clean keyword list for query expansion. Numbers, unit words (e.g. `مل`),
|
|
22
|
+
special characters and empty strings are removed. Hyphenated words are both split and joined
|
|
23
|
+
(`anti-aging` → `anti`, `aging`, `antiaging`), and article-stripped forms are added (`للشعر` → `شعر`, `بالصبار` → `صبار`).
|
|
24
|
+
- **CLI and batch mode** with JSON / JSON Lines output.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install git+https://github.com/mtanzil6/souqnorm.git@v0.1.0
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
For development:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
git clone https://github.com/mtanzil6/souqnorm.git
|
|
36
|
+
cd souqnorm
|
|
37
|
+
pip install -e ".[dev]"
|
|
38
|
+
pytest
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from souqnorm import process
|
|
45
|
+
|
|
46
|
+
r = process("شامبـــو للشعر الجاف ٤٠٠مل")
|
|
47
|
+
r.normalized # 'شامبو للشعر الجاف 400 مل'
|
|
48
|
+
r.quantities[0].label # '400 ml'
|
|
49
|
+
r.quantities[0].match_key() # 'qty:400ml'
|
|
50
|
+
r.text_without_quantities # 'شامبو للشعر الجاف'
|
|
51
|
+
r.keywords # ['شامبو', 'للشعر', 'شعر', 'الجاف', 'جاف']
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
souqnorm "عطر ٣٫٤ fl oz"
|
|
56
|
+
souqnorm --file queries.txt > normalized.jsonl
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Custom configuration:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from souqnorm import NormalizerConfig, QueryNormalizer
|
|
63
|
+
qn = QueryNormalizer(NormalizerConfig(taa_marbuta_to_haa=False), strip_article=False)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Reproducing the evaluation
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
python benchmarks/build_benchmark.py # synthetic catalog + perturbed queries (seeded)
|
|
70
|
+
python benchmarks/run_benchmark.py # writes benchmarks/results/results.json
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
To evaluate on a real catalog (not distributed here), run:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install openpyxl
|
|
77
|
+
python benchmarks/real_titles.py path/to/titles.xlsx # writes benchmarks/results/real_titles.json
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The `build_benchmark.py` retrieval benchmark is **synthetic**. It uses fictional brands and template titles, and its queries are made by
|
|
81
|
+
applying common Arabic spelling variations. It shows how each normalization step affects results, but it is not a
|
|
82
|
+
substitute for evaluation on real query logs.
|
|
83
|
+
|
|
84
|
+
## Known limitations
|
|
85
|
+
|
|
86
|
+
- Article stripping is a heuristic and can wrongly shorten transliterated brand names that start with فال/بال
|
|
87
|
+
(it only adds variants and keeps the original keyword).
|
|
88
|
+
- `oz` is treated as fluid ounces (volume), the usual meaning in beauty retail.
|
|
89
|
+
- There is no spelling correction, stemming beyond articles, or Arabizi (Latin-script Arabic) transliteration yet.
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT © 2026 Muhammad Tanzil
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "souqnorm"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Normalization, size parsing and keyword expansion for Arabic/English e-commerce search queries"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Muhammad Tanzil", email = "mtanzil666@gmail.com" }]
|
|
14
|
+
keywords = ["arabic", "nlp", "e-commerce", "search", "query normalization", "information retrieval"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Natural Language :: Arabic",
|
|
18
|
+
"Topic :: Text Processing :: Linguistic",
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/mtanzil6/souqnorm"
|
|
24
|
+
Issues = "https://github.com/mtanzil6/souqnorm/issues"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest>=7"]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
souqnorm = "souqnorm.cli:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
addopts = "--doctest-modules"
|
|
37
|
+
testpaths = ["tests", "src"]
|
souqnorm-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""souqnorm: normalization, size parsing and keyword expansion for Arabic/English e-commerce search queries."""
|
|
2
|
+
from .arabic import NormalizerConfig, normalize_text
|
|
3
|
+
from .pipeline import NormalizedQuery, QueryNormalizer, process
|
|
4
|
+
from .units import UNIT_LEXICON, Quantity, UnitParser
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
__all__ = ["NormalizerConfig", "normalize_text", "NormalizedQuery", "QueryNormalizer", "process",
|
|
8
|
+
"UNIT_LEXICON", "Quantity", "UnitParser", "__version__"]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Character-level normalization for Arabic/English product search queries."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import unicodedata
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
# Harakat, Quranic annotation marks, superscript alef.
|
|
9
|
+
_DIACRITICS = re.compile(r"[ؐ-ًؚ-ٰٟۖ-ۭ]")
|
|
10
|
+
_TATWEEL = "ـ"
|
|
11
|
+
|
|
12
|
+
_ALEF = str.maketrans({"أ": "ا", "إ": "ا", "آ": "ا", "ٱ": "ا"})
|
|
13
|
+
_ALEF_MAQSURA = str.maketrans({"ى": "ي"})
|
|
14
|
+
_TAA_MARBUTA = str.maketrans({"ة": "ه"})
|
|
15
|
+
_HAMZA_CARRIERS = str.maketrans({"ؤ": "و", "ئ": "ي"})
|
|
16
|
+
_DIGITS = str.maketrans("٠١٢٣٤٥٦٧٨٩۰۱۲۳۴۵۶۷۸۹", "0123456789" * 2)
|
|
17
|
+
_PUNCT = str.maketrans({"٫": ".", "٬": ",", "،": ",", "؛": ";", "؟": "?", "٪": "%", "×": "x"})
|
|
18
|
+
|
|
19
|
+
_DECIMAL_COMMA = re.compile(r"(?<=\d),(?=\d{1,2}\b)")
|
|
20
|
+
_WS = re.compile(r"\s+")
|
|
21
|
+
|
|
22
|
+
AR_RANGE = "-ۿݐ-ݿࢠ-ࣿ"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class NormalizerConfig:
|
|
27
|
+
"""Switches for each normalization step (all on by default)."""
|
|
28
|
+
|
|
29
|
+
unicode_nfkc: bool = True
|
|
30
|
+
digits: bool = True
|
|
31
|
+
punctuation: bool = True
|
|
32
|
+
remove_diacritics: bool = True
|
|
33
|
+
remove_tatweel: bool = True
|
|
34
|
+
unify_alef: bool = True
|
|
35
|
+
alef_maqsura_to_yaa: bool = True
|
|
36
|
+
taa_marbuta_to_haa: bool = True
|
|
37
|
+
hamza_carriers: bool = True
|
|
38
|
+
lowercase: bool = True
|
|
39
|
+
split_scripts: bool = True # "50مل" -> "50 مل", "spf50" -> "spf 50"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_SPLIT_DIGIT = re.compile(r"(?<=\d)(?=[^\d\s.,%])|(?<=[^\d\s.,])(?=\d)")
|
|
43
|
+
_SPLIT_SCRIPT = re.compile(rf"(?<=[{AR_RANGE}])(?=[a-z])|(?<=[a-z])(?=[{AR_RANGE}])")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def normalize_text(text: str, config: NormalizerConfig | None = None) -> str:
|
|
47
|
+
"""Return a normalized form of *text* suitable for indexing and matching."""
|
|
48
|
+
c = config or NormalizerConfig()
|
|
49
|
+
s = text
|
|
50
|
+
if c.unicode_nfkc:
|
|
51
|
+
s = unicodedata.normalize("NFKC", s)
|
|
52
|
+
if c.digits:
|
|
53
|
+
s = s.translate(_DIGITS)
|
|
54
|
+
if c.punctuation:
|
|
55
|
+
s = s.translate(_PUNCT)
|
|
56
|
+
s = _DECIMAL_COMMA.sub(".", s)
|
|
57
|
+
if c.remove_diacritics:
|
|
58
|
+
s = _DIACRITICS.sub("", s)
|
|
59
|
+
if c.remove_tatweel:
|
|
60
|
+
s = s.replace(_TATWEEL, "")
|
|
61
|
+
if c.unify_alef:
|
|
62
|
+
s = s.translate(_ALEF)
|
|
63
|
+
if c.alef_maqsura_to_yaa:
|
|
64
|
+
s = s.translate(_ALEF_MAQSURA)
|
|
65
|
+
if c.taa_marbuta_to_haa:
|
|
66
|
+
s = s.translate(_TAA_MARBUTA)
|
|
67
|
+
if c.hamza_carriers:
|
|
68
|
+
s = s.translate(_HAMZA_CARRIERS)
|
|
69
|
+
if c.lowercase:
|
|
70
|
+
s = s.lower()
|
|
71
|
+
if c.split_scripts:
|
|
72
|
+
s = _SPLIT_DIGIT.sub(" ", s)
|
|
73
|
+
s = _SPLIT_SCRIPT.sub(" ", s)
|
|
74
|
+
return _WS.sub(" ", s).strip()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Command-line interface: `souqnorm "query"` or `souqnorm --file queries.txt`."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from .arabic import NormalizerConfig
|
|
9
|
+
from .pipeline import QueryNormalizer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv: list[str] | None = None) -> int:
|
|
13
|
+
p = argparse.ArgumentParser(prog="souqnorm", description="Normalize Arabic/English product search queries.")
|
|
14
|
+
p.add_argument("query", nargs="*", help="query text (omit when using --file)")
|
|
15
|
+
p.add_argument("-f", "--file", help="read one query per line ('-' for stdin); writes JSON Lines")
|
|
16
|
+
p.add_argument("--keep-taa-marbuta", action="store_true", help="do not map ة to ه")
|
|
17
|
+
p.add_argument("--keep-hamza", action="store_true", help="do not map ؤ/ئ to و/ي")
|
|
18
|
+
p.add_argument("--no-article-strip", action="store_true", help="do not add ال-stripped keywords")
|
|
19
|
+
a = p.parse_args(argv)
|
|
20
|
+
|
|
21
|
+
cfg = NormalizerConfig(taa_marbuta_to_haa=not a.keep_taa_marbuta, hamza_carriers=not a.keep_hamza)
|
|
22
|
+
qn = QueryNormalizer(cfg, strip_article=not a.no_article_strip)
|
|
23
|
+
|
|
24
|
+
if a.file:
|
|
25
|
+
fh = sys.stdin if a.file == "-" else open(a.file, encoding="utf-8")
|
|
26
|
+
with fh:
|
|
27
|
+
for line in fh:
|
|
28
|
+
if line.strip():
|
|
29
|
+
print(json.dumps(qn.process(line.strip()).to_dict(), ensure_ascii=False))
|
|
30
|
+
return 0
|
|
31
|
+
if not a.query:
|
|
32
|
+
p.error("provide a query or --file")
|
|
33
|
+
print(json.dumps(qn.process(" ".join(a.query)).to_dict(), ensure_ascii=False, indent=2))
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""End-to-end query processing: normalize -> extract quantities -> generate keywords and variants."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from .arabic import AR_RANGE, NormalizerConfig, normalize_text
|
|
8
|
+
from .units import Quantity, UnitParser
|
|
9
|
+
|
|
10
|
+
_TOKEN = re.compile(rf"[{AR_RANGE}a-z0-9]+(?:[-'.][{AR_RANGE}a-z0-9]+)*")
|
|
11
|
+
_NUMERIC = re.compile(r"^\d+(?:\.\d+)?$")
|
|
12
|
+
# definite-article forms, longest first: wa-al, bi-al, ka-al, fa-al, li-l, al
|
|
13
|
+
_ARTICLE_PREFIXES = ("وال", "بال", "كال", "فال", "لل", "ال")
|
|
14
|
+
# Function words dropped from keywords. "او" is deliberately kept: in catalogs it is usually "eau" (او دو بارفيوم).
|
|
15
|
+
DEFAULT_STOPWORDS = frozenset({"من", "في", "مع", "علي", "الي", "عن", "لـ", "و", "ب", "ل", "الذي", "التي",
|
|
16
|
+
"for", "with", "and", "of", "the", "by", "from", "in", "to", "a", "an"})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class NormalizedQuery:
|
|
21
|
+
original: str
|
|
22
|
+
normalized: str
|
|
23
|
+
tokens: list[str]
|
|
24
|
+
quantities: list[Quantity]
|
|
25
|
+
text_without_quantities: str
|
|
26
|
+
keywords: list[str]
|
|
27
|
+
variants: list[str] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict:
|
|
30
|
+
return {
|
|
31
|
+
"original": self.original,
|
|
32
|
+
"normalized": self.normalized,
|
|
33
|
+
"tokens": self.tokens,
|
|
34
|
+
"quantities": [q.to_dict() for q in self.quantities],
|
|
35
|
+
"text_without_quantities": self.text_without_quantities,
|
|
36
|
+
"keywords": self.keywords,
|
|
37
|
+
"variants": self.variants,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _dedupe(items):
|
|
42
|
+
seen, out = set(), []
|
|
43
|
+
for x in items:
|
|
44
|
+
if x and x not in seen:
|
|
45
|
+
seen.add(x)
|
|
46
|
+
out.append(x)
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class QueryNormalizer:
|
|
51
|
+
"""Main entry point.
|
|
52
|
+
|
|
53
|
+
>>> qn = QueryNormalizer()
|
|
54
|
+
>>> r = qn.process("شامبو للشعر الجاف ٤٠٠مل")
|
|
55
|
+
>>> r.normalized
|
|
56
|
+
'شامبو للشعر الجاف 400 مل'
|
|
57
|
+
>>> r.quantities[0].label
|
|
58
|
+
'400 ml'
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, config: NormalizerConfig | None = None, strip_article: bool = True,
|
|
62
|
+
min_keyword_len: int = 2, stopwords: frozenset[str] | None = DEFAULT_STOPWORDS):
|
|
63
|
+
self.config = config or NormalizerConfig()
|
|
64
|
+
self.units = UnitParser(self.config)
|
|
65
|
+
self.strip_article = strip_article
|
|
66
|
+
self.min_keyword_len = min_keyword_len
|
|
67
|
+
self._unit_words = self.units.unit_words
|
|
68
|
+
self.stopwords = frozenset(normalize_text(w, self.config) for w in (stopwords or ()))
|
|
69
|
+
|
|
70
|
+
def normalize(self, text: str) -> str:
|
|
71
|
+
return normalize_text(text, self.config)
|
|
72
|
+
|
|
73
|
+
def tokenize(self, normalized: str) -> list[str]:
|
|
74
|
+
return _TOKEN.findall(normalized)
|
|
75
|
+
|
|
76
|
+
def keywords(self, tokens: list[str]) -> list[str]:
|
|
77
|
+
"""Alternative keywords: drops numbers, unit words (e.g. 'مل'), stopwords, special characters
|
|
78
|
+
and empty strings; splits and joins hyphenated words; optionally adds article-stripped forms."""
|
|
79
|
+
out: list[str] = []
|
|
80
|
+
for tok in tokens:
|
|
81
|
+
parts = [p for p in re.split(r"[-'.]", tok) if p]
|
|
82
|
+
candidates = parts + (["".join(parts)] if len(parts) > 1 else [])
|
|
83
|
+
for c in candidates:
|
|
84
|
+
if _NUMERIC.match(c) or c.isdigit() or c in self._unit_words:
|
|
85
|
+
continue
|
|
86
|
+
if len(c) < self.min_keyword_len or c in self.stopwords:
|
|
87
|
+
continue
|
|
88
|
+
out.append(c)
|
|
89
|
+
if self.strip_article:
|
|
90
|
+
for pre in _ARTICLE_PREFIXES:
|
|
91
|
+
if c.startswith(pre) and len(c) - len(pre) >= 3:
|
|
92
|
+
out.append(c[len(pre):])
|
|
93
|
+
break
|
|
94
|
+
return _dedupe(out)
|
|
95
|
+
|
|
96
|
+
def process(self, text: str) -> NormalizedQuery:
|
|
97
|
+
norm = self.normalize(text)
|
|
98
|
+
quantities = self.units.parse(norm)
|
|
99
|
+
stripped = norm
|
|
100
|
+
for q in reversed(quantities):
|
|
101
|
+
stripped = stripped[: q.start] + " " + stripped[q.end:]
|
|
102
|
+
stripped = re.sub(r"\s+", " ", stripped).strip()
|
|
103
|
+
tokens = self.tokenize(norm)
|
|
104
|
+
kws = self.keywords(self.tokenize(stripped))
|
|
105
|
+
canonical = stripped
|
|
106
|
+
if quantities:
|
|
107
|
+
canonical = (stripped + " " + " ".join(q.label for q in quantities)).strip()
|
|
108
|
+
variants = _dedupe([norm, canonical, stripped, " ".join(kws)])
|
|
109
|
+
return NormalizedQuery(text, norm, tokens, quantities, stripped, kws, variants)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_default: QueryNormalizer | None = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def process(text: str) -> NormalizedQuery:
|
|
116
|
+
"""Process *text* with a shared default :class:`QueryNormalizer`."""
|
|
117
|
+
global _default
|
|
118
|
+
if _default is None:
|
|
119
|
+
_default = QueryNormalizer()
|
|
120
|
+
return _default.process(text)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Extraction and canonicalization of size/quantity expressions (e.g. '50 مل', '1.7oz', '2x100ml')."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
|
|
8
|
+
from .arabic import NormalizerConfig, normalize_text
|
|
9
|
+
|
|
10
|
+
# canonical unit -> (base unit, factor to base, surface forms in natural spelling)
|
|
11
|
+
UNIT_LEXICON: dict[str, tuple[str, float, tuple[str, ...]]] = {
|
|
12
|
+
"ml": ("ml", 1.0, ("ml", "mls", "milliliter", "milliliters", "millilitre", "millilitres",
|
|
13
|
+
"مل", "ملل", "مللي", "ملي", "مليلتر", "ملليلتر", "ميليلتر")),
|
|
14
|
+
"l": ("ml", 1000.0, ("l", "ltr", "ltrs", "liter", "liters", "litre", "litres", "لتر", "لترات", "ليتر")),
|
|
15
|
+
"fl_oz": ("ml", 29.5735, ("fl oz", "fl. oz", "fl.oz", "floz")),
|
|
16
|
+
"oz": ("ml", 29.5735, ("oz", "ounce", "ounces", "أونصة", "اونصة", "أوقية", "اوقية")),
|
|
17
|
+
"g": ("g", 1.0, ("g", "gm", "gms", "gr", "grm", "gram", "grams", "gramme",
|
|
18
|
+
"غ", "غم", "غرام", "جم", "جرام", "قرام", "ج")),
|
|
19
|
+
"kg": ("g", 1000.0, ("kg", "kgs", "kilo", "kilogram", "kilograms",
|
|
20
|
+
"كغ", "كجم", "كلغ", "كيلو", "كيلوغرام", "كيلوجرام")),
|
|
21
|
+
"mg": ("g", 0.001, ("mg", "ملغ", "ملجم", "مجم", "ملغم", "مليغرام", "مليجرام")),
|
|
22
|
+
"lb": ("g", 453.592, ("lb", "lbs", "pound", "pounds", "باوند", "رطل")),
|
|
23
|
+
"pcs": ("pcs", 1.0, ("pc", "pcs", "piece", "pieces", "count", "ct", "capsules", "caps", "tablets", "tabs",
|
|
24
|
+
"sachets", "patches", "ampoules", "wipes", "pads",
|
|
25
|
+
"قطعة", "قطع", "حبة", "حبات", "كبسولة", "كبسولات", "قرص", "أقراص", "منديل", "مناديل",
|
|
26
|
+
"كيس", "أكياس", "لصقة", "لصقات", "أمبولة", "أمبولات", "وحدة", "وحدات", "قطنة",
|
|
27
|
+
"شريحة", "شرائح", "ظرف", "أظرف")),
|
|
28
|
+
# length (dimensions of tools, cables, towels); multipliers are read as dimensions, not packs
|
|
29
|
+
"mm": ("mm", 1.0, ("mm", "ملم", "مم", "مليمتر", "ملليمتر")),
|
|
30
|
+
"cm": ("mm", 10.0, ("cm", "سم", "سنتيمتر", "سنتي")),
|
|
31
|
+
"m": ("mm", 1000.0, ("m", "م", "متر", "meter", "meters", "metre", "metres")),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_LENGTH = {"mm", "cm", "m"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Quantity:
|
|
39
|
+
value: float # numeric value as written (per item)
|
|
40
|
+
unit: str # canonical unit key, e.g. "ml", "fl_oz"
|
|
41
|
+
multiplier: int # pack multiplier, 1 if absent ("2x50ml" -> 2)
|
|
42
|
+
base_value: float # value * factor * multiplier in the base unit
|
|
43
|
+
base_unit: str # "ml", "g", "pcs" or "mm"
|
|
44
|
+
text: str # matched surface text (in normalized query)
|
|
45
|
+
start: int
|
|
46
|
+
end: int
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def label(self) -> str:
|
|
50
|
+
unit = "fl oz" if self.unit == "fl_oz" else self.unit
|
|
51
|
+
core = f"{self.value:g} {unit}"
|
|
52
|
+
return f"{self.multiplier} x {core}" if self.multiplier > 1 else core
|
|
53
|
+
|
|
54
|
+
def match_key(self, sig: int = 2) -> str:
|
|
55
|
+
"""Unit-independent key for matching sizes across unit systems, rounding the base value to
|
|
56
|
+
*sig* significant figures (e.g. 400 ml and 13.5 fl oz -> 'qty:400ml')."""
|
|
57
|
+
v = self.base_value
|
|
58
|
+
if v > 0:
|
|
59
|
+
v = round(v, sig - 1 - int(math.floor(math.log10(abs(v)))))
|
|
60
|
+
return f"qty:{v:g}{self.base_unit}"
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict:
|
|
63
|
+
d = asdict(self)
|
|
64
|
+
d["match_key"] = self.match_key()
|
|
65
|
+
d["label"] = self.label
|
|
66
|
+
return d
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class UnitParser:
|
|
70
|
+
"""Finds quantity expressions in text that was produced by :func:`normalize_text`."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, config: NormalizerConfig | None = None):
|
|
73
|
+
self.config = config or NormalizerConfig()
|
|
74
|
+
self._surface_to_unit: dict[str, str] = {}
|
|
75
|
+
for unit, (_, _, forms) in UNIT_LEXICON.items():
|
|
76
|
+
for form in forms:
|
|
77
|
+
self._surface_to_unit[normalize_text(form, self.config)] = unit
|
|
78
|
+
alts = sorted(self._surface_to_unit, key=len, reverse=True)
|
|
79
|
+
unit_re = "|".join(re.escape(a) for a in alts)
|
|
80
|
+
self._pattern = re.compile(
|
|
81
|
+
rf"(?:(?P<mult>\d+)\s*[x*]\s*)?(?P<num>\d+(?:\.\d+)?)\s*(?P<unit>{unit_re})(?![\w])"
|
|
82
|
+
r"(?!\s*(?:امبير|امب|amp|ampere|a\b|h\b))" # "7000 ملي امبير" / "mAh" is battery capacity
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def unit_words(self) -> frozenset[str]:
|
|
87
|
+
"""Normalized measurement-unit tokens (volume, mass, length), e.g. {'مل', 'ml', 'جم', ...}.
|
|
88
|
+
Count nouns such as 'مناديل' or 'امبوله' are excluded because they also name products."""
|
|
89
|
+
return frozenset(w for s, u in self._surface_to_unit.items() if UNIT_LEXICON[u][0] != "pcs"
|
|
90
|
+
for w in s.split())
|
|
91
|
+
|
|
92
|
+
def parse(self, normalized: str) -> list[Quantity]:
|
|
93
|
+
out: list[Quantity] = []
|
|
94
|
+
for m in self._pattern.finditer(normalized):
|
|
95
|
+
unit = self._surface_to_unit[m.group("unit")]
|
|
96
|
+
base_unit, factor, _ = UNIT_LEXICON[unit]
|
|
97
|
+
value = float(m.group("num"))
|
|
98
|
+
mult = int(m.group("mult")) if m.group("mult") else 1
|
|
99
|
+
start = m.start()
|
|
100
|
+
if unit in _LENGTH and mult > 1: # "38*29.5 سم" is a size, not a pack of 38
|
|
101
|
+
mult, start = 1, m.start("num")
|
|
102
|
+
out.append(Quantity(value, unit, mult, round(value * factor * mult, 3), base_unit,
|
|
103
|
+
normalized[start:m.end()], start, m.end()))
|
|
104
|
+
return out
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: souqnorm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Normalization, size parsing and keyword expansion for Arabic/English e-commerce search queries
|
|
5
|
+
Author-email: Muhammad Tanzil <mtanzil666@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mtanzil6/souqnorm
|
|
8
|
+
Project-URL: Issues, https://github.com/mtanzil6/souqnorm/issues
|
|
9
|
+
Keywords: arabic,nlp,e-commerce,search,query normalization,information retrieval
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Natural Language :: Arabic
|
|
12
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# souqnorm
|
|
21
|
+
|
|
22
|
+
**Normalization, size parsing and keyword expansion for Arabic/English e-commerce search queries.**
|
|
23
|
+
|
|
24
|
+
Shoppers in Arabic-speaking markets type the same product many ways: `شامبو ٤٠٠مل`, `شامبو 400 ملل`,
|
|
25
|
+
`شاـــمبو 400ml`, `shampoo 13.5 fl oz`. Off-the-shelf search analyzers treat each of these as different
|
|
26
|
+
text. `souqnorm` is a small, dependency-free Python library that turns these forms into one consistent
|
|
27
|
+
representation before indexing or querying, whatever search engine you use.
|
|
28
|
+
|
|
29
|
+
## Features (v0.1)
|
|
30
|
+
|
|
31
|
+
- **Arabic orthographic normalization.** It unifies alef forms (أ إ آ ٱ → ا), maps ى → ي, ة → ه and ؤ/ئ → و/ي,
|
|
32
|
+
and strips diacritics (harakat) and tatweel. It also converts Arabic-Indic and Persian digits and the Arabic
|
|
33
|
+
decimal mark (٫), applies NFKC, lowercases Latin text and splits number/script boundaries
|
|
34
|
+
(`400مل` → `400 مل`). Every step can be switched off in `NormalizerConfig`.
|
|
35
|
+
- **Size and quantity parsing.** It recognizes Arabic and English unit spellings for volume (مل/ملل/مللي/ml, لتر/l,
|
|
36
|
+
oz/fl oz/أونصة), mass (جم/ج/غرام/g, كجم/kg, ملغ/mg, باوند/lb), counts (قطعة/حبة/كبسولة/قرص/منديل/كيس/pcs) and
|
|
37
|
+
length (ملم/سم/م/mm/cm/m), plus pack multipliers (`2×50 مل`, `10x6مل`). Battery capacity (`7000 ملي امبير`) is
|
|
38
|
+
not mistaken for volume. It converts each size to a
|
|
39
|
+
base unit (ml, g, pcs, mm) and gives it a unit-independent `match_key` (400 ml ≡ 13.5 fl oz ≡ 0.4 l).
|
|
40
|
+
- **Alternative keywords.** It builds a clean keyword list for query expansion. Numbers, unit words (e.g. `مل`),
|
|
41
|
+
special characters and empty strings are removed. Hyphenated words are both split and joined
|
|
42
|
+
(`anti-aging` → `anti`, `aging`, `antiaging`), and article-stripped forms are added (`للشعر` → `شعر`, `بالصبار` → `صبار`).
|
|
43
|
+
- **CLI and batch mode** with JSON / JSON Lines output.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install git+https://github.com/mtanzil6/souqnorm.git@v0.1.0
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
For development:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
git clone https://github.com/mtanzil6/souqnorm.git
|
|
55
|
+
cd souqnorm
|
|
56
|
+
pip install -e ".[dev]"
|
|
57
|
+
pytest
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from souqnorm import process
|
|
64
|
+
|
|
65
|
+
r = process("شامبـــو للشعر الجاف ٤٠٠مل")
|
|
66
|
+
r.normalized # 'شامبو للشعر الجاف 400 مل'
|
|
67
|
+
r.quantities[0].label # '400 ml'
|
|
68
|
+
r.quantities[0].match_key() # 'qty:400ml'
|
|
69
|
+
r.text_without_quantities # 'شامبو للشعر الجاف'
|
|
70
|
+
r.keywords # ['شامبو', 'للشعر', 'شعر', 'الجاف', 'جاف']
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
souqnorm "عطر ٣٫٤ fl oz"
|
|
75
|
+
souqnorm --file queries.txt > normalized.jsonl
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Custom configuration:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from souqnorm import NormalizerConfig, QueryNormalizer
|
|
82
|
+
qn = QueryNormalizer(NormalizerConfig(taa_marbuta_to_haa=False), strip_article=False)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Reproducing the evaluation
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
python benchmarks/build_benchmark.py # synthetic catalog + perturbed queries (seeded)
|
|
89
|
+
python benchmarks/run_benchmark.py # writes benchmarks/results/results.json
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
To evaluate on a real catalog (not distributed here), run:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pip install openpyxl
|
|
96
|
+
python benchmarks/real_titles.py path/to/titles.xlsx # writes benchmarks/results/real_titles.json
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The `build_benchmark.py` retrieval benchmark is **synthetic**. It uses fictional brands and template titles, and its queries are made by
|
|
100
|
+
applying common Arabic spelling variations. It shows how each normalization step affects results, but it is not a
|
|
101
|
+
substitute for evaluation on real query logs.
|
|
102
|
+
|
|
103
|
+
## Known limitations
|
|
104
|
+
|
|
105
|
+
- Article stripping is a heuristic and can wrongly shorten transliterated brand names that start with فال/بال
|
|
106
|
+
(it only adds variants and keeps the original keyword).
|
|
107
|
+
- `oz` is treated as fluid ounces (volume), the usual meaning in beauty retail.
|
|
108
|
+
- There is no spelling correction, stemming beyond articles, or Arabizi (Latin-script Arabic) transliteration yet.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT © 2026 Muhammad Tanzil
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/souqnorm/__init__.py
|
|
5
|
+
src/souqnorm/arabic.py
|
|
6
|
+
src/souqnorm/cli.py
|
|
7
|
+
src/souqnorm/pipeline.py
|
|
8
|
+
src/souqnorm/units.py
|
|
9
|
+
src/souqnorm.egg-info/PKG-INFO
|
|
10
|
+
src/souqnorm.egg-info/SOURCES.txt
|
|
11
|
+
src/souqnorm.egg-info/dependency_links.txt
|
|
12
|
+
src/souqnorm.egg-info/entry_points.txt
|
|
13
|
+
src/souqnorm.egg-info/requires.txt
|
|
14
|
+
src/souqnorm.egg-info/top_level.txt
|
|
15
|
+
tests/test_souqnorm.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
souqnorm
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from souqnorm import NormalizerConfig, QueryNormalizer, normalize_text, process
|
|
6
|
+
from souqnorm.cli import main
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.mark.parametrize("raw, expected", [
|
|
10
|
+
("أحمر شفاه", "احمر شفاه"),
|
|
11
|
+
("إيلاف", "ايلاف"),
|
|
12
|
+
("آمن", "امن"),
|
|
13
|
+
("مُرَطِّب", "مرطب"),
|
|
14
|
+
("الدهنيـــة", "الدهنيه"),
|
|
15
|
+
("مستشفى", "مستشفي"),
|
|
16
|
+
("لؤلؤ", "لولو"),
|
|
17
|
+
("مضيئة", "مضييه"),
|
|
18
|
+
("٤٠٠", "400"),
|
|
19
|
+
("۵۰", "50"),
|
|
20
|
+
("٣٫٤", "3.4"),
|
|
21
|
+
("3,4 oz", "3.4 oz"),
|
|
22
|
+
("1,000", "1,000"),
|
|
23
|
+
("Shampoo ANTI-Dandruff", "shampoo anti-dandruff"),
|
|
24
|
+
("400مل", "400 مل"),
|
|
25
|
+
("spf50", "spf 50"),
|
|
26
|
+
("كريمcream", "كريم cream"),
|
|
27
|
+
("ﻻ", "لا"), # presentation form via NFKC
|
|
28
|
+
])
|
|
29
|
+
def test_normalize_text(raw, expected):
|
|
30
|
+
assert normalize_text(raw) == expected
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_config_switches():
|
|
34
|
+
cfg = NormalizerConfig(taa_marbuta_to_haa=False, unify_alef=False)
|
|
35
|
+
assert normalize_text("أداة", cfg) == "أداة"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.parametrize("query, label, base", [
|
|
39
|
+
("شامبو ٤٠٠مل", "400 ml", 400),
|
|
40
|
+
("شامبو 400 ملل", "400 ml", 400),
|
|
41
|
+
("شامبو 400 مللي", "400 ml", 400),
|
|
42
|
+
("lotion 250ML", "250 ml", 250),
|
|
43
|
+
("زيت 1 لتر", "1 l", 1000),
|
|
44
|
+
("perfume 3.4 fl oz", "3.4 fl oz", 100.55),
|
|
45
|
+
("serum 1.7oz", "1.7 oz", 50.275),
|
|
46
|
+
("عطر ١٠٠ مل", "100 ml", 100),
|
|
47
|
+
("كريم 50 جم", "50 g", 50),
|
|
48
|
+
("powder 1.5kg", "1.5 kg", 1500),
|
|
49
|
+
("فيتامين 500 ملغ", "500 mg", 0.5),
|
|
50
|
+
("مناديل 3 قطع", "3 pcs", 3),
|
|
51
|
+
("2x100ml", "2 x 100 ml", 200),
|
|
52
|
+
("٢×٥٠ مل", "2 x 50 ml", 100),
|
|
53
|
+
])
|
|
54
|
+
def test_quantities(query, label, base):
|
|
55
|
+
qs = process(query).quantities
|
|
56
|
+
assert len(qs) == 1
|
|
57
|
+
assert qs[0].label == label
|
|
58
|
+
assert qs[0].base_value == pytest.approx(base, rel=1e-3)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.parametrize("query", ["spf 50 lotion", "50 غسول", "عطر رقم 5", "vitamin c 20%"])
|
|
62
|
+
def test_no_false_quantities(query):
|
|
63
|
+
assert process(query).quantities == []
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_keywords_filter_numbers_units_and_hyphens():
|
|
67
|
+
r = process("كريم anti-aging للبشرة 50 مل")
|
|
68
|
+
assert "مل" not in r.keywords
|
|
69
|
+
assert "50" not in r.keywords
|
|
70
|
+
assert all("-" not in k and " " not in k and k for k in r.keywords)
|
|
71
|
+
assert {"anti", "aging", "antiaging"} <= set(r.keywords)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_article_stripping():
|
|
75
|
+
r = process("شامبو للشعر الجاف بالصبار")
|
|
76
|
+
assert {"شعر", "جاف", "صبار"} <= set(r.keywords)
|
|
77
|
+
assert "شعر" not in QueryNormalizer(strip_article=False).process("للشعر").keywords
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_variants_and_text_without_quantities():
|
|
81
|
+
r = process("شامبو للشعر 400مل")
|
|
82
|
+
assert r.text_without_quantities == "شامبو للشعر"
|
|
83
|
+
assert "شامبو للشعر 400 ml" in r.variants
|
|
84
|
+
assert r.variants[0] == r.normalized
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_to_dict_is_json_serializable():
|
|
88
|
+
json.dumps(process("عطر 100 مل").to_dict(), ensure_ascii=False)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_cli_single(capsys):
|
|
92
|
+
assert main(["عطر", "١٠٠مل"]) == 0
|
|
93
|
+
out = json.loads(capsys.readouterr().out)
|
|
94
|
+
assert out["quantities"][0]["label"] == "100 ml"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_cli_file(tmp_path, capsys):
|
|
98
|
+
f = tmp_path / "q.txt"
|
|
99
|
+
f.write_text("عطر 50 مل\n\nشامبو\n", encoding="utf-8")
|
|
100
|
+
assert main(["--file", str(f)]) == 0
|
|
101
|
+
lines = capsys.readouterr().out.strip().splitlines()
|
|
102
|
+
assert len(lines) == 2
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_match_key_cross_unit():
|
|
106
|
+
a = process("شامبو 400 مل").quantities[0].match_key()
|
|
107
|
+
b = process("shampoo 13.5 fl oz").quantities[0].match_key()
|
|
108
|
+
c = process("شامبو 0.4 لتر").quantities[0].match_key()
|
|
109
|
+
assert a == b == c == "qty:400ml"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pytest.mark.parametrize("query, label", [
|
|
113
|
+
("جل شعر - 340ج", "340 g"),
|
|
114
|
+
("كبسولات فيتامين - 60 كبسولة", "60 pcs"),
|
|
115
|
+
("مناديل - 48 منديل", "48 pcs"),
|
|
116
|
+
("بروتين - 1.6 باوند", "1.6 lb"),
|
|
117
|
+
("كيبل شحن بطول 1.2م", "1.2 m"),
|
|
118
|
+
("مجعد الشعر - 32 ملم", "32 mm"),
|
|
119
|
+
("حقيبة - 38*29.5سم", "29.5 cm"),
|
|
120
|
+
("أمبولة - 10x6مل", "10 x 6 ml"),
|
|
121
|
+
])
|
|
122
|
+
def test_real_catalog_patterns(query, label):
|
|
123
|
+
assert [q.label for q in process(query).quantities] == [label]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@pytest.mark.parametrize("query", ["بطارية 7000 ملي امبير", "power bank 10000 mah"])
|
|
127
|
+
def test_battery_capacity_not_volume(query):
|
|
128
|
+
assert process(query).quantities == []
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_stopwords_and_count_nouns_in_keywords():
|
|
132
|
+
r = process("أمبولة لتساقط الشعر من كيراستاس - 10x6مل")
|
|
133
|
+
assert "من" not in r.keywords
|
|
134
|
+
assert "امبوله" in r.keywords
|
|
135
|
+
assert "مل" not in r.keywords
|
|
136
|
+
assert "او" in process("عطر او دو بارفيوم").keywords
|