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 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.
@@ -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,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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ souqnorm = souqnorm.cli:main
@@ -0,0 +1,3 @@
1
+
2
+ [dev]
3
+ pytest>=7
@@ -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