@whiteslove/parsing-lexicon 0.2.0 → 0.2.2

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.
Files changed (94) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +137 -137
  3. package/index.d.ts +374 -374
  4. package/package.json +77 -75
  5. package/src/central-asia-locations.js +211 -211
  6. package/src/central-asia.js +233 -233
  7. package/src/contact.d.ts +44 -44
  8. package/src/contact.js +159 -159
  9. package/src/countries.js +175 -175
  10. package/src/country-context.d.ts +10 -10
  11. package/src/country-context.js +22 -22
  12. package/src/currency.d.ts +14 -14
  13. package/src/currency.js +66 -66
  14. package/src/geo.js +169 -169
  15. package/src/geography-central-asia.js +42 -42
  16. package/src/geography-detection.d.ts +14 -14
  17. package/src/geography-detection.js +64 -64
  18. package/src/geography-display.d.ts +13 -13
  19. package/src/geography-display.js +113 -113
  20. package/src/geography.js +210 -210
  21. package/src/hiring-advanced.js +147 -147
  22. package/src/hiring-candidate-fields.d.ts +9 -9
  23. package/src/hiring-candidate-fields.js +92 -92
  24. package/src/hiring-context.d.ts +64 -64
  25. package/src/hiring-context.js +412 -412
  26. package/src/hiring-language-extensions.d.ts +19 -19
  27. package/src/hiring-language-extensions.js +139 -139
  28. package/src/hiring-languages.js +135 -135
  29. package/src/hiring-location-fields.d.ts +3 -3
  30. package/src/hiring-location-fields.js +53 -53
  31. package/src/hiring-professions-ro.js +191 -191
  32. package/src/hiring-professions.js +437 -437
  33. package/src/hiring-requirements.d.ts +16 -16
  34. package/src/hiring-requirements.js +170 -170
  35. package/src/hiring-salary-context.d.ts +27 -27
  36. package/src/hiring-salary-context.js +76 -76
  37. package/src/hiring-semantics.d.ts +29 -29
  38. package/src/hiring-semantics.js +218 -218
  39. package/src/hiring-skills.d.ts +21 -21
  40. package/src/hiring-skills.js +315 -315
  41. package/src/hiring-source-aliases.d.ts +32 -32
  42. package/src/hiring-source-aliases.js +185 -185
  43. package/src/hiring-source-semantics.d.ts +31 -31
  44. package/src/hiring-source-semantics.js +293 -293
  45. package/src/hiring-temporal.d.ts +15 -15
  46. package/src/hiring-temporal.js +67 -67
  47. package/src/hiring-vacancy-fields.d.ts +5 -5
  48. package/src/hiring-vacancy-fields.js +23 -23
  49. package/src/hiring-work-semantics.d.ts +34 -34
  50. package/src/hiring-work-semantics.js +72 -72
  51. package/src/hiring.js +180 -180
  52. package/src/housing-address.d.ts +18 -18
  53. package/src/housing-address.js +217 -210
  54. package/src/housing-commercial.d.ts +1 -0
  55. package/src/housing-commercial.js +9 -0
  56. package/src/housing-context.d.ts +36 -36
  57. package/src/housing-context.js +183 -183
  58. package/src/housing-features.js +30 -30
  59. package/src/housing-intent.d.ts +9 -9
  60. package/src/housing-intent.js +132 -132
  61. package/src/housing-listing-fields.js +131 -131
  62. package/src/housing-money.d.ts +7 -7
  63. package/src/housing-money.js +102 -102
  64. package/src/housing-source-aliases.d.ts +8 -8
  65. package/src/housing-source-aliases.js +51 -51
  66. package/src/housing-structured.d.ts +55 -55
  67. package/src/housing-structured.js +219 -219
  68. package/src/housing.js +200 -200
  69. package/src/index.js +45 -44
  70. package/src/kz-location-extensions.js +307 -307
  71. package/src/landmarks.js +21 -21
  72. package/src/lexicon-core.js +58 -58
  73. package/src/location-merge.js +106 -106
  74. package/src/locations.js +456 -456
  75. package/src/money-core.d.ts +10 -10
  76. package/src/money-core.js +81 -81
  77. package/src/money-lexicon.d.ts +5 -5
  78. package/src/money-lexicon.js +79 -79
  79. package/src/money.js +154 -154
  80. package/src/normalization.js +384 -384
  81. package/src/odesa-metropolitan.js +144 -144
  82. package/src/romania-geography.js +47 -47
  83. package/src/tashkent-colloquial.js +80 -72
  84. package/src/tashkent-housing-geography.d.ts +38 -38
  85. package/src/tashkent-housing-geography.js +211 -211
  86. package/src/tashkent-pois.js +215 -215
  87. package/src/tashkent-residential-complexes.js +272 -272
  88. package/src/ua-location-extensions-major.js +118 -118
  89. package/src/ua-location-extensions-metro.js +9 -9
  90. package/src/ua-location-extensions-regional.js +246 -246
  91. package/src/ua-secondary-cities.d.ts +2 -2
  92. package/src/ua-secondary-cities.js +57 -57
  93. package/src/ukraine.js +75 -75
  94. package/src/uz-location-extensions.js +259 -259
package/package.json CHANGED
@@ -1,75 +1,77 @@
1
- {
2
- "name": "@whiteslove/parsing-lexicon",
3
- "version": "0.2.0",
4
- "description": "Shared deterministic multilingual parsing lexicon for Whiteslove housing and hiring services",
5
- "repository": {
6
- "type": "git",
7
- "url": "git+https://github.com/AmoneMisa/parsing-lexicon.git"
8
- },
9
- "type": "module",
10
- "main": "./src/index.js",
11
- "types": "./index.d.ts",
12
- "exports": {
13
- ".": { "types": "./index.d.ts", "import": "./src/index.js" },
14
- "./lexicon-core": "./src/lexicon-core.js",
15
- "./normalization": "./src/normalization.js",
16
- "./countries": "./src/countries.js",
17
- "./country-context": { "types": "./src/country-context.d.ts", "import": "./src/country-context.js" },
18
- "./geo": "./src/geo.js",
19
- "./geography": "./src/geography.js",
20
- "./geography-display": { "types": "./src/geography-display.d.ts", "import": "./src/geography-display.js" },
21
- "./geography-detection": { "types": "./src/geography-detection.d.ts", "import": "./src/geography-detection.js" },
22
- "./central-asia": "./src/central-asia.js",
23
- "./ukraine": "./src/ukraine.js",
24
- "./location-merge": "./src/location-merge.js",
25
- "./locations": "./src/locations.js",
26
- "./kz-location-extensions": "./src/kz-location-extensions.js",
27
- "./uz-location-extensions": "./src/uz-location-extensions.js",
28
- "./central-asia-locations": "./src/central-asia-locations.js",
29
- "./ua-location-extensions-major": "./src/ua-location-extensions-major.js",
30
- "./ua-location-extensions-regional": "./src/ua-location-extensions-regional.js",
31
- "./ua-location-extensions-metro": "./src/ua-location-extensions-metro.js",
32
- "./landmarks": "./src/landmarks.js",
33
- "./tashkent-colloquial": "./src/tashkent-colloquial.js",
34
- "./tashkent-residential-complexes": "./src/tashkent-residential-complexes.js",
35
- "./tashkent-pois": "./src/tashkent-pois.js",
36
- "./tashkent-housing-geography": { "types": "./src/tashkent-housing-geography.d.ts", "import": "./src/tashkent-housing-geography.js" },
37
- "./odesa-metropolitan": "./src/odesa-metropolitan.js",
38
- "./housing": "./src/housing.js",
39
- "./housing-intent": { "types": "./src/housing-intent.d.ts", "import": "./src/housing-intent.js" },
40
- "./housing-context": { "types": "./src/housing-context.d.ts", "import": "./src/housing-context.js" },
41
- "./housing-address": { "types": "./src/housing-address.d.ts", "import": "./src/housing-address.js" },
42
- "./housing-structured": { "types": "./src/housing-structured.d.ts", "import": "./src/housing-structured.js" },
43
- "./housing-source-aliases": { "types": "./src/housing-source-aliases.d.ts", "import": "./src/housing-source-aliases.js" },
44
- "./hiring": "./src/hiring.js",
45
- "./hiring-languages": "./src/hiring-languages.js",
46
- "./hiring-language-extensions": { "types": "./src/hiring-language-extensions.d.ts", "import": "./src/hiring-language-extensions.js" },
47
- "./hiring-context": { "types": "./src/hiring-context.d.ts", "import": "./src/hiring-context.js" },
48
- "./hiring-source-aliases": { "types": "./src/hiring-source-aliases.d.ts", "import": "./src/hiring-source-aliases.js" },
49
- "./hiring-source-semantics": { "types": "./src/hiring-source-semantics.d.ts", "import": "./src/hiring-source-semantics.js" },
50
- "./hiring-requirements": { "types": "./src/hiring-requirements.d.ts", "import": "./src/hiring-requirements.js" },
51
- "./hiring-semantics": { "types": "./src/hiring-semantics.d.ts", "import": "./src/hiring-semantics.js" },
52
- "./hiring-work-semantics": { "types": "./src/hiring-work-semantics.d.ts", "import": "./src/hiring-work-semantics.js" },
53
- "./hiring-candidate-fields": { "types": "./src/hiring-candidate-fields.d.ts", "import": "./src/hiring-candidate-fields.js" },
54
- "./hiring-temporal": { "types": "./src/hiring-temporal.d.ts", "import": "./src/hiring-temporal.js" },
55
- "./hiring-vacancy-fields": { "types": "./src/hiring-vacancy-fields.d.ts", "import": "./src/hiring-vacancy-fields.js" },
56
- "./hiring-location-fields": { "types": "./src/hiring-location-fields.d.ts", "import": "./src/hiring-location-fields.js" },
57
- "./hiring-salary-context": { "types": "./src/hiring-salary-context.d.ts", "import": "./src/hiring-salary-context.js" },
58
- "./money": "./src/money.js",
59
- "./currency": { "types": "./src/currency.d.ts", "import": "./src/currency.js" },
60
- "./contact": { "types": "./src/contact.d.ts", "import": "./src/contact.js" },
61
- "./housing-money": { "types": "./src/housing-money.d.ts", "import": "./src/housing-money.js" },
62
- "./hiring-professions": "./src/hiring-professions.js",
63
- "./hiring-skills": { "types": "./src/hiring-skills.d.ts", "import": "./src/hiring-skills.js" },
64
- "./hiring-advanced": "./src/hiring-advanced.js"
65
- },
66
- "files": ["src", "index.d.ts", "README.md"],
67
- "sideEffects": false,
68
- "engines": { "node": ">=20" },
69
- "scripts": { "test": "node --test" },
70
- "dependencies": {
71
- "libphonenumber-js": "^1.11.20"
72
- },
73
- "keywords": ["parsing", "lexicon", "uzbek", "kazakh", "ukrainian", "romanian", "russian", "housing", "hiring", "salary", "professions", "geography"],
74
- "license": "MIT"
75
- }
1
+ {
2
+ "name": "@whiteslove/parsing-lexicon",
3
+ "version": "0.2.2",
4
+ "description": "Shared deterministic multilingual parsing lexicon for Whiteslove housing and hiring services",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/AmoneMisa/parsing-lexicon.git"
8
+ },
9
+ "type": "module",
10
+ "main": "./src/index.js",
11
+ "types": "./index.d.ts",
12
+ "exports": {
13
+ ".": { "types": "./index.d.ts", "import": "./src/index.js" },
14
+ "./lexicon-core": "./src/lexicon-core.js",
15
+ "./normalization": "./src/normalization.js",
16
+ "./countries": "./src/countries.js",
17
+ "./country-context": { "types": "./src/country-context.d.ts", "import": "./src/country-context.js" },
18
+ "./geo": "./src/geo.js",
19
+ "./geography": "./src/geography.js",
20
+ "./geography-display": { "types": "./src/geography-display.d.ts", "import": "./src/geography-display.js" },
21
+ "./geography-detection": { "types": "./src/geography-detection.d.ts", "import": "./src/geography-detection.js" },
22
+ "./central-asia": "./src/central-asia.js",
23
+ "./ukraine": "./src/ukraine.js",
24
+ "./location-merge": "./src/location-merge.js",
25
+ "./locations": "./src/locations.js",
26
+ "./kz-location-extensions": "./src/kz-location-extensions.js",
27
+ "./uz-location-extensions": "./src/uz-location-extensions.js",
28
+ "./central-asia-locations": "./src/central-asia-locations.js",
29
+ "./ua-location-extensions-major": "./src/ua-location-extensions-major.js",
30
+ "./ua-location-extensions-regional": "./src/ua-location-extensions-regional.js",
31
+ "./ua-location-extensions-metro": "./src/ua-location-extensions-metro.js",
32
+ "./landmarks": "./src/landmarks.js",
33
+ "./tashkent-colloquial": "./src/tashkent-colloquial.js",
34
+ "./tashkent-residential-complexes": "./src/tashkent-residential-complexes.js",
35
+ "./tashkent-pois": "./src/tashkent-pois.js",
36
+ "./tashkent-housing-geography": { "types": "./src/tashkent-housing-geography.d.ts", "import": "./src/tashkent-housing-geography.js" },
37
+ "./odesa-metropolitan": "./src/odesa-metropolitan.js",
38
+ "./housing": "./src/housing.js",
39
+ "./housing-intent": { "types": "./src/housing-intent.d.ts", "import": "./src/housing-intent.js" },
40
+ "./housing-commercial": { "types": "./src/housing-commercial.d.ts", "import": "./src/housing-commercial.js" },
41
+ "./housing-context": { "types": "./src/housing-context.d.ts", "import": "./src/housing-context.js" },
42
+ "./housing-address": { "types": "./src/housing-address.d.ts", "import": "./src/housing-address.js" },
43
+ "./housing-structured": { "types": "./src/housing-structured.d.ts", "import": "./src/housing-structured.js" },
44
+ "./housing-source-aliases": { "types": "./src/housing-source-aliases.d.ts", "import": "./src/housing-source-aliases.js" },
45
+ "./hiring": "./src/hiring.js",
46
+ "./hiring-languages": "./src/hiring-languages.js",
47
+ "./hiring-language-extensions": { "types": "./src/hiring-language-extensions.d.ts", "import": "./src/hiring-language-extensions.js" },
48
+ "./hiring-context": { "types": "./src/hiring-context.d.ts", "import": "./src/hiring-context.js" },
49
+ "./hiring-source-aliases": { "types": "./src/hiring-source-aliases.d.ts", "import": "./src/hiring-source-aliases.js" },
50
+ "./hiring-source-semantics": { "types": "./src/hiring-source-semantics.d.ts", "import": "./src/hiring-source-semantics.js" },
51
+ "./hiring-requirements": { "types": "./src/hiring-requirements.d.ts", "import": "./src/hiring-requirements.js" },
52
+ "./hiring-semantics": { "types": "./src/hiring-semantics.d.ts", "import": "./src/hiring-semantics.js" },
53
+ "./hiring-work-semantics": { "types": "./src/hiring-work-semantics.d.ts", "import": "./src/hiring-work-semantics.js" },
54
+ "./hiring-candidate-fields": { "types": "./src/hiring-candidate-fields.d.ts", "import": "./src/hiring-candidate-fields.js" },
55
+ "./hiring-temporal": { "types": "./src/hiring-temporal.d.ts", "import": "./src/hiring-temporal.js" },
56
+ "./hiring-vacancy-fields": { "types": "./src/hiring-vacancy-fields.d.ts", "import": "./src/hiring-vacancy-fields.js" },
57
+ "./hiring-location-fields": { "types": "./src/hiring-location-fields.d.ts", "import": "./src/hiring-location-fields.js" },
58
+ "./hiring-salary-context": { "types": "./src/hiring-salary-context.d.ts", "import": "./src/hiring-salary-context.js" },
59
+ "./money": "./src/money.js",
60
+ "./currency": { "types": "./src/currency.d.ts", "import": "./src/currency.js" },
61
+ "./contact": { "types": "./src/contact.d.ts", "import": "./src/contact.js" },
62
+ "./housing-money": { "types": "./src/housing-money.d.ts", "import": "./src/housing-money.js" },
63
+ "./hiring-professions": "./src/hiring-professions.js",
64
+ "./hiring-skills": { "types": "./src/hiring-skills.d.ts", "import": "./src/hiring-skills.js" },
65
+ "./hiring-advanced": "./src/hiring-advanced.js"
66
+ },
67
+ "files": ["src", "index.d.ts", "README.md"],
68
+ "sideEffects": false,
69
+ "engines": { "node": ">=20" },
70
+ "scripts": { "test": "node --test" },
71
+ "publishConfig": { "access": "public" },
72
+ "dependencies": {
73
+ "libphonenumber-js": "^1.11.20"
74
+ },
75
+ "keywords": ["parsing", "lexicon", "uzbek", "kazakh", "ukrainian", "romanian", "russian", "housing", "hiring", "salary", "professions", "geography"],
76
+ "license": "MIT"
77
+ }
@@ -1,211 +1,211 @@
1
- import { LOCATION_DICTIONARIES } from './locations.js';
2
- import { LOCATION_LIST_KEYS, mergeLocationCountries } from './location-merge.js';
3
- import { aliasesOf, aliasesToRegex, normalizeForMatch } from './normalization.js';
4
- import {
5
- KZ_CITY_CATALOG,
6
- UZ_CITY_CATALOG,
7
- canonicalKazakhstanCity,
8
- canonicalUzbekistanCity,
9
- } from './central-asia.js';
10
- import { KZ_AMBIGUOUS_LOCAL_NAMES, KZ_LOCATION_EXTENSIONS, KZ_SEARCH_CLUSTERS } from './kz-location-extensions.js';
11
- import { UZ_AMBIGUOUS_LOCAL_NAMES, UZ_LOCATION_EXTENSIONS } from './uz-location-extensions.js';
12
-
13
- export const KZ_EXPANDED_LOCATION_DICTIONARIES = mergeLocationCountries(
14
- LOCATION_DICTIONARIES.KZ || {},
15
- KZ_LOCATION_EXTENSIONS,
16
- );
17
-
18
- export const UZ_EXPANDED_LOCATION_DICTIONARIES = mergeLocationCountries(
19
- LOCATION_DICTIONARIES.UZ || {},
20
- UZ_LOCATION_EXTENSIONS,
21
- );
22
-
23
- export const CENTRAL_ASIA_LOCATION_DICTIONARIES = Object.freeze({
24
- KZ: KZ_EXPANDED_LOCATION_DICTIONARIES,
25
- UZ: UZ_EXPANDED_LOCATION_DICTIONARIES,
26
- });
27
-
28
- const TYPE_BY_KEY = Object.freeze({
29
- districts: 'district',
30
- microdistricts: 'microdistrict',
31
- mahallas: 'mahalla',
32
- localAreas: 'local_area',
33
- suburbs: 'suburb',
34
- settlements: 'settlement',
35
- metro: 'metro',
36
- residentialComplexes: 'residential_complex',
37
- streets: 'street',
38
- landmarks: 'poi',
39
- pois: 'poi',
40
- searchClusters: 'search_cluster',
41
- });
42
-
43
- const GENERIC_AMBIGUOUS = new Set([
44
- 'center', 'old city', 'new city', 'airport', 'railway station area', 'university area',
45
- 'central park', 'embankment', 'bazaar', 'market', 'north', 'east', 'south west',
46
- ]);
47
-
48
- const KZ_AMBIGUOUS = new Set(KZ_AMBIGUOUS_LOCAL_NAMES.map(normalizeForMatch));
49
- const UZ_AMBIGUOUS = new Set(UZ_AMBIGUOUS_LOCAL_NAMES.map(normalizeForMatch));
50
-
51
- function cityCatalog(countryCode) {
52
- return countryCode === 'KZ' ? KZ_CITY_CATALOG : countryCode === 'UZ' ? UZ_CITY_CATALOG : [];
53
- }
54
-
55
- function dictionaries(countryCode) {
56
- return CENTRAL_ASIA_LOCATION_DICTIONARIES[countryCode] || {};
57
- }
58
-
59
- function canonicalCity(value, countryCode) {
60
- return countryCode === 'KZ'
61
- ? canonicalKazakhstanCity(value)
62
- : countryCode === 'UZ'
63
- ? canonicalUzbekistanCity(value)
64
- : null;
65
- }
66
-
67
- const cityRegexCache = new Map();
68
- function cityRegex(item) {
69
- const key = `${item.country}:${item.canonical}`;
70
- if (!cityRegexCache.has(key)) {
71
- cityRegexCache.set(key, aliasesToRegex([item.canonical, ...aliasesOf(item)]));
72
- }
73
- return cityRegexCache.get(key);
74
- }
75
-
76
- function explicitCityFromText(text, countryCode) {
77
- const value = String(text || '');
78
- const matches = [];
79
- for (const item of cityCatalog(countryCode)) {
80
- const match = value.match(cityRegex(item));
81
- if (!match) continue;
82
- if (item.contextRequired) {
83
- const start = match.index || 0;
84
- const before = value.slice(Math.max(0, start - 40), start);
85
- const after = value.slice(start + match[0].length, start + match[0].length + 48);
86
- if (!/(?:shahri|город|viloyati|область|andijon|андижан)/iu.test(`${before} ${after}`)) continue;
87
- }
88
- matches.push({ item, length: normalizeForMatch(match[0]).length });
89
- }
90
- matches.sort((a, b) => b.length - a.length);
91
- return matches[0]?.item?.canonical || null;
92
- }
93
-
94
- function isExplicitMetroContext(value, match) {
95
- const start = match.index ?? 0;
96
- const end = start + match[0].length;
97
- const before = value.slice(Math.max(0, start - 36), start);
98
- const after = value.slice(end, end + 36);
99
- return /(?:метро|metro|станц(?:ия|ии)?|station|ст\.?\s*м\.?|м\.)\s*[:\-–—]?\s*$/iu.test(before)
100
- || /^\s*(?:метро|metro|station|станц(?:ия|ии)?)(?=$|[^\p{L}\p{N}_])/iu.test(after);
101
- }
102
-
103
- function metroOverlapsArea(item, data) {
104
- const itemKeys = new Set([item.name, ...(item.aliases || [])].map(normalizeForMatch).filter(Boolean));
105
- return ['microdistricts', 'localAreas'].some((key) => (data?.[key] || []).some((area) =>
106
- [area.name, ...(area.aliases || [])].map(normalizeForMatch).some((value) => itemKeys.has(value)),
107
- ));
108
- }
109
-
110
- function findEntryMatches(text, cityName, data) {
111
- const value = String(text || '');
112
- const matches = [];
113
- for (const key of LOCATION_LIST_KEYS) {
114
- for (const item of data?.[key] || []) {
115
- const match = value.match(item?.re);
116
- if (!match) continue;
117
- if (key === 'metro' && metroOverlapsArea(item, data) && !isExplicitMetroContext(value, match)) continue;
118
- matches.push(Object.freeze({
119
- country: item.country || null,
120
- city: cityName,
121
- type: item.entityType || TYPE_BY_KEY[key] || key,
122
- key,
123
- name: item.name,
124
- aliases: item.aliases || [],
125
- district: item.district || null,
126
- parent: item.parent || item.district || null,
127
- confidence: item.confidence || null,
128
- language: item.language || null,
129
- }));
130
- }
131
- }
132
- return matches;
133
- }
134
-
135
- function isAmbiguousMatch(match, countryCode) {
136
- const keys = [match.name, ...(match.aliases || [])].map(normalizeForMatch).filter(Boolean);
137
- if (keys.some((key) => GENERIC_AMBIGUOUS.has(key))) return true;
138
- if (countryCode === 'KZ' && keys.some((key) => KZ_AMBIGUOUS.has(key))) return true;
139
- if (countryCode === 'UZ' && keys.some((key) => UZ_AMBIGUOUS.has(key))) return true;
140
- return false;
141
- }
142
-
143
- function clusterMatches(matches, countryCode) {
144
- if (countryCode !== 'KZ') return [];
145
- const names = new Set(matches.map((item) => normalizeForMatch(item.name)));
146
- return KZ_SEARCH_CLUSTERS.filter((cluster) => cluster.members.some((member) => names.has(normalizeForMatch(member))));
147
- }
148
-
149
- export function centralAsiaLocationCities(countryCode) {
150
- return dictionaries(countryCode);
151
- }
152
-
153
- export function centralAsiaLocationCity(countryCode, city) {
154
- const canonical = canonicalCity(city, countryCode) || city;
155
- return dictionaries(countryCode)[canonical] || null;
156
- }
157
-
158
- export function matchCentralAsiaLocationEntities(text, countryCode, preferredCity = null) {
159
- if (!text || !['KZ', 'UZ'].includes(countryCode)) {
160
- return Object.freeze({ city: null, matches: Object.freeze([]), searchClusters: Object.freeze([]), candidates: Object.freeze([]) });
161
- }
162
-
163
- const country = dictionaries(countryCode);
164
- const preferred = canonicalCity(preferredCity, countryCode) || preferredCity;
165
- const explicit = explicitCityFromText(text, countryCode);
166
- const scopedCity = preferred && country[preferred] ? preferred : explicit && country[explicit] ? explicit : null;
167
-
168
- if (scopedCity) {
169
- const matches = findEntryMatches(text, scopedCity, country[scopedCity]);
170
- const clusters = clusterMatches(matches, countryCode);
171
- return Object.freeze({ city: scopedCity, matches: Object.freeze(matches), searchClusters: Object.freeze(clusters), candidates: Object.freeze([]) });
172
- }
173
-
174
- const byCity = [];
175
- for (const [cityName, data] of Object.entries(country)) {
176
- const matches = findEntryMatches(text, cityName, data);
177
- if (matches.length) byCity.push({ city: cityName, matches });
178
- }
179
-
180
- if (!byCity.length) {
181
- return Object.freeze({ city: null, matches: Object.freeze([]), searchClusters: Object.freeze([]), candidates: Object.freeze([]) });
182
- }
183
-
184
- // Numeric microdistricts and common names such as Samal/Center occur in many
185
- // cities. Without an explicit/structured city we must not silently assign a
186
- // parent. Prefer a city only when it owns at least one non-ambiguous match
187
- // that no other city matched.
188
- const strong = byCity.filter((candidate) => candidate.matches.some((match) => !isAmbiguousMatch(match, countryCode)));
189
- const selected = strong.length === 1
190
- ? strong[0]
191
- : byCity.length === 1 && byCity[0].matches.some((match) => !isAmbiguousMatch(match, countryCode))
192
- ? byCity[0]
193
- : null;
194
-
195
- if (!selected) {
196
- return Object.freeze({
197
- city: null,
198
- matches: Object.freeze([]),
199
- searchClusters: Object.freeze([]),
200
- candidates: Object.freeze(byCity.map((candidate) => Object.freeze({ city: candidate.city, matches: Object.freeze(candidate.matches) }))),
201
- });
202
- }
203
-
204
- const clusters = clusterMatches(selected.matches, countryCode);
205
- return Object.freeze({ city: selected.city, matches: Object.freeze(selected.matches), searchClusters: Object.freeze(clusters), candidates: Object.freeze([]) });
206
- }
207
-
208
- export function matchCentralAsiaLocationEntity(text, countryCode, preferredCity = null, type = null) {
209
- const result = matchCentralAsiaLocationEntities(text, countryCode, preferredCity);
210
- return result.matches.find((item) => !type || item.type === type || item.key === type) || null;
211
- }
1
+ import { LOCATION_DICTIONARIES } from './locations.js';
2
+ import { LOCATION_LIST_KEYS, mergeLocationCountries } from './location-merge.js';
3
+ import { aliasesOf, aliasesToRegex, normalizeForMatch } from './normalization.js';
4
+ import {
5
+ KZ_CITY_CATALOG,
6
+ UZ_CITY_CATALOG,
7
+ canonicalKazakhstanCity,
8
+ canonicalUzbekistanCity,
9
+ } from './central-asia.js';
10
+ import { KZ_AMBIGUOUS_LOCAL_NAMES, KZ_LOCATION_EXTENSIONS, KZ_SEARCH_CLUSTERS } from './kz-location-extensions.js';
11
+ import { UZ_AMBIGUOUS_LOCAL_NAMES, UZ_LOCATION_EXTENSIONS } from './uz-location-extensions.js';
12
+
13
+ export const KZ_EXPANDED_LOCATION_DICTIONARIES = mergeLocationCountries(
14
+ LOCATION_DICTIONARIES.KZ || {},
15
+ KZ_LOCATION_EXTENSIONS,
16
+ );
17
+
18
+ export const UZ_EXPANDED_LOCATION_DICTIONARIES = mergeLocationCountries(
19
+ LOCATION_DICTIONARIES.UZ || {},
20
+ UZ_LOCATION_EXTENSIONS,
21
+ );
22
+
23
+ export const CENTRAL_ASIA_LOCATION_DICTIONARIES = Object.freeze({
24
+ KZ: KZ_EXPANDED_LOCATION_DICTIONARIES,
25
+ UZ: UZ_EXPANDED_LOCATION_DICTIONARIES,
26
+ });
27
+
28
+ const TYPE_BY_KEY = Object.freeze({
29
+ districts: 'district',
30
+ microdistricts: 'microdistrict',
31
+ mahallas: 'mahalla',
32
+ localAreas: 'local_area',
33
+ suburbs: 'suburb',
34
+ settlements: 'settlement',
35
+ metro: 'metro',
36
+ residentialComplexes: 'residential_complex',
37
+ streets: 'street',
38
+ landmarks: 'poi',
39
+ pois: 'poi',
40
+ searchClusters: 'search_cluster',
41
+ });
42
+
43
+ const GENERIC_AMBIGUOUS = new Set([
44
+ 'center', 'old city', 'new city', 'airport', 'railway station area', 'university area',
45
+ 'central park', 'embankment', 'bazaar', 'market', 'north', 'east', 'south west',
46
+ ]);
47
+
48
+ const KZ_AMBIGUOUS = new Set(KZ_AMBIGUOUS_LOCAL_NAMES.map(normalizeForMatch));
49
+ const UZ_AMBIGUOUS = new Set(UZ_AMBIGUOUS_LOCAL_NAMES.map(normalizeForMatch));
50
+
51
+ function cityCatalog(countryCode) {
52
+ return countryCode === 'KZ' ? KZ_CITY_CATALOG : countryCode === 'UZ' ? UZ_CITY_CATALOG : [];
53
+ }
54
+
55
+ function dictionaries(countryCode) {
56
+ return CENTRAL_ASIA_LOCATION_DICTIONARIES[countryCode] || {};
57
+ }
58
+
59
+ function canonicalCity(value, countryCode) {
60
+ return countryCode === 'KZ'
61
+ ? canonicalKazakhstanCity(value)
62
+ : countryCode === 'UZ'
63
+ ? canonicalUzbekistanCity(value)
64
+ : null;
65
+ }
66
+
67
+ const cityRegexCache = new Map();
68
+ function cityRegex(item) {
69
+ const key = `${item.country}:${item.canonical}`;
70
+ if (!cityRegexCache.has(key)) {
71
+ cityRegexCache.set(key, aliasesToRegex([item.canonical, ...aliasesOf(item)]));
72
+ }
73
+ return cityRegexCache.get(key);
74
+ }
75
+
76
+ function explicitCityFromText(text, countryCode) {
77
+ const value = String(text || '');
78
+ const matches = [];
79
+ for (const item of cityCatalog(countryCode)) {
80
+ const match = value.match(cityRegex(item));
81
+ if (!match) continue;
82
+ if (item.contextRequired) {
83
+ const start = match.index || 0;
84
+ const before = value.slice(Math.max(0, start - 40), start);
85
+ const after = value.slice(start + match[0].length, start + match[0].length + 48);
86
+ if (!/(?:shahri|город|viloyati|область|andijon|андижан)/iu.test(`${before} ${after}`)) continue;
87
+ }
88
+ matches.push({ item, length: normalizeForMatch(match[0]).length });
89
+ }
90
+ matches.sort((a, b) => b.length - a.length);
91
+ return matches[0]?.item?.canonical || null;
92
+ }
93
+
94
+ function isExplicitMetroContext(value, match) {
95
+ const start = match.index ?? 0;
96
+ const end = start + match[0].length;
97
+ const before = value.slice(Math.max(0, start - 36), start);
98
+ const after = value.slice(end, end + 36);
99
+ return /(?:метро|metro|станц(?:ия|ии)?|station|ст\.?\s*м\.?|м\.)\s*[:\-–—]?\s*$/iu.test(before)
100
+ || /^\s*(?:метро|metro|station|станц(?:ия|ии)?)(?=$|[^\p{L}\p{N}_])/iu.test(after);
101
+ }
102
+
103
+ function metroOverlapsArea(item, data) {
104
+ const itemKeys = new Set([item.name, ...(item.aliases || [])].map(normalizeForMatch).filter(Boolean));
105
+ return ['microdistricts', 'localAreas'].some((key) => (data?.[key] || []).some((area) =>
106
+ [area.name, ...(area.aliases || [])].map(normalizeForMatch).some((value) => itemKeys.has(value)),
107
+ ));
108
+ }
109
+
110
+ function findEntryMatches(text, cityName, data) {
111
+ const value = String(text || '');
112
+ const matches = [];
113
+ for (const key of LOCATION_LIST_KEYS) {
114
+ for (const item of data?.[key] || []) {
115
+ const match = value.match(item?.re);
116
+ if (!match) continue;
117
+ if (key === 'metro' && metroOverlapsArea(item, data) && !isExplicitMetroContext(value, match)) continue;
118
+ matches.push(Object.freeze({
119
+ country: item.country || null,
120
+ city: cityName,
121
+ type: item.entityType || TYPE_BY_KEY[key] || key,
122
+ key,
123
+ name: item.name,
124
+ aliases: item.aliases || [],
125
+ district: item.district || null,
126
+ parent: item.parent || item.district || null,
127
+ confidence: item.confidence || null,
128
+ language: item.language || null,
129
+ }));
130
+ }
131
+ }
132
+ return matches;
133
+ }
134
+
135
+ function isAmbiguousMatch(match, countryCode) {
136
+ const keys = [match.name, ...(match.aliases || [])].map(normalizeForMatch).filter(Boolean);
137
+ if (keys.some((key) => GENERIC_AMBIGUOUS.has(key))) return true;
138
+ if (countryCode === 'KZ' && keys.some((key) => KZ_AMBIGUOUS.has(key))) return true;
139
+ if (countryCode === 'UZ' && keys.some((key) => UZ_AMBIGUOUS.has(key))) return true;
140
+ return false;
141
+ }
142
+
143
+ function clusterMatches(matches, countryCode) {
144
+ if (countryCode !== 'KZ') return [];
145
+ const names = new Set(matches.map((item) => normalizeForMatch(item.name)));
146
+ return KZ_SEARCH_CLUSTERS.filter((cluster) => cluster.members.some((member) => names.has(normalizeForMatch(member))));
147
+ }
148
+
149
+ export function centralAsiaLocationCities(countryCode) {
150
+ return dictionaries(countryCode);
151
+ }
152
+
153
+ export function centralAsiaLocationCity(countryCode, city) {
154
+ const canonical = canonicalCity(city, countryCode) || city;
155
+ return dictionaries(countryCode)[canonical] || null;
156
+ }
157
+
158
+ export function matchCentralAsiaLocationEntities(text, countryCode, preferredCity = null) {
159
+ if (!text || !['KZ', 'UZ'].includes(countryCode)) {
160
+ return Object.freeze({ city: null, matches: Object.freeze([]), searchClusters: Object.freeze([]), candidates: Object.freeze([]) });
161
+ }
162
+
163
+ const country = dictionaries(countryCode);
164
+ const preferred = canonicalCity(preferredCity, countryCode) || preferredCity;
165
+ const explicit = explicitCityFromText(text, countryCode);
166
+ const scopedCity = preferred && country[preferred] ? preferred : explicit && country[explicit] ? explicit : null;
167
+
168
+ if (scopedCity) {
169
+ const matches = findEntryMatches(text, scopedCity, country[scopedCity]);
170
+ const clusters = clusterMatches(matches, countryCode);
171
+ return Object.freeze({ city: scopedCity, matches: Object.freeze(matches), searchClusters: Object.freeze(clusters), candidates: Object.freeze([]) });
172
+ }
173
+
174
+ const byCity = [];
175
+ for (const [cityName, data] of Object.entries(country)) {
176
+ const matches = findEntryMatches(text, cityName, data);
177
+ if (matches.length) byCity.push({ city: cityName, matches });
178
+ }
179
+
180
+ if (!byCity.length) {
181
+ return Object.freeze({ city: null, matches: Object.freeze([]), searchClusters: Object.freeze([]), candidates: Object.freeze([]) });
182
+ }
183
+
184
+ // Numeric microdistricts and common names such as Samal/Center occur in many
185
+ // cities. Without an explicit/structured city we must not silently assign a
186
+ // parent. Prefer a city only when it owns at least one non-ambiguous match
187
+ // that no other city matched.
188
+ const strong = byCity.filter((candidate) => candidate.matches.some((match) => !isAmbiguousMatch(match, countryCode)));
189
+ const selected = strong.length === 1
190
+ ? strong[0]
191
+ : byCity.length === 1 && byCity[0].matches.some((match) => !isAmbiguousMatch(match, countryCode))
192
+ ? byCity[0]
193
+ : null;
194
+
195
+ if (!selected) {
196
+ return Object.freeze({
197
+ city: null,
198
+ matches: Object.freeze([]),
199
+ searchClusters: Object.freeze([]),
200
+ candidates: Object.freeze(byCity.map((candidate) => Object.freeze({ city: candidate.city, matches: Object.freeze(candidate.matches) }))),
201
+ });
202
+ }
203
+
204
+ const clusters = clusterMatches(selected.matches, countryCode);
205
+ return Object.freeze({ city: selected.city, matches: Object.freeze(selected.matches), searchClusters: Object.freeze(clusters), candidates: Object.freeze([]) });
206
+ }
207
+
208
+ export function matchCentralAsiaLocationEntity(text, countryCode, preferredCity = null, type = null) {
209
+ const result = matchCentralAsiaLocationEntities(text, countryCode, preferredCity);
210
+ return result.matches.find((item) => !type || item.type === type || item.key === type) || null;
211
+ }