في مقال خرائطك على سيرفرك مع OpenStreetMap وMapLibre وضعنا خريطة الرياض كاملة على سيرفرنا، ولكن الخريطة وحدها لا تكفي أغلب التطبيقات، فالمستخدم يكتب «مستشفى الملك فيصل التخصصي» ويريد أن يراه على الخريطة، والمندوب يرسل إحداثيات GPS من هاتفه وتريد أن تعرف في أي حي هو. وهذه العملية اسمها البحث عن العناوين Geocoding، والطريقة البسيطة هي أن ترسل كل بحث إلى Google أو إلى السيرفر العام لمشروع OpenStreetMap، ولكن الأول يحسب عليك كل طلب ويرى كل عنوان يبحث عنه عملاؤك، والثاني له سياسة استخدام صارمة سوف نقرؤها بعد قليل.
والحل الذي نشرحه هنا هو Nominatim، وهو البرنامج المفتوح المصدر نفسه الذي يشغل البحث في موقع OpenStreetMap، بترخيص GPL، على سيرفرك وبياناتك. وهو المقال الثاني في مرحلة الخدمات المتخصصة: الخرائط والبث المباشر ومعالجة الوسائط، وسوف نناقش فيه ما يلي:
- ما هو البحث عن العناوين Geocoding والبحث العكسي Reverse Geocoding.
- كيف يعمل Nominatim من الداخل، ولماذا يحتاج استيراداً طويلاً مرة واحدة.
- سياسة استخدام السيرفر العام، ومتى تحتاج سيرفرك الخاص.
- مختبر كامل على Docker: استيراد مستطيل الرياض، مع قياس الوقت والذاكرة، وأسئلة بالعربية والإنجليزية بمخرجاتها الحقيقية.
- ما يجيده Nominatim بالعربية وما يفوته، وربطه بالخريطة من المقال السابق.
ما هو البحث عن العناوين Geocoding؟
البحث عن العناوين Geocoding هو تحويل نص يكتبه إنسان، اسم مكان أو عنوان، إلى إحداثيات خط العرض Latitude وخط الطول Longitude، والبحث العكسي Reverse Geocoding هو العكس: تعطيه نقطة فيعيد لك أقرب عنوان لها. والصورة التالية تبين الاتجاهين بإجابتين حقيقيتين من المختبر:

ولاحظ في المثال الثاني أن البحث العكسي لا يخترع عنواناً، وإنما يعيد أقرب جسم مناسب موجود في البيانات، وتوثيق البحث العكسي يقول ذلك صراحة، وينبه إلى أن هذا قد يعطي نتائج غير متوقعة في المدن المزدحمة، وسوف نرى ذلك بأعيننا في المختبر.
كيف يعمل Nominatim من الداخل؟
Nominatim ليس خدمة تبحث في ملف OpenStreetMap عند كل طلب، والسبب أن البحث في ملايين العناصر وبناء عنوان كل عنصر من الحدود الإدارية حوله عمل ثقيل، لذلك يقسم العمل إلى مرحلتين: استيراد مرة واحدة يبني كل شيء في قاعدة البيانات، ثم أسئلة سريعة على ما بني، والصورة التالية تبين المرحلتين:

- الاستيراد Import: أداة osm2pgsql تقرأ ملف OpenStreetMap بصيغة PBF وتكتبه في PostgreSQL مع إضافة PostGIS، وهي الإضافة الجغرافية التي شرحناها في قسم PostGIS في مقال PostgreSQL لكل شيء.
- الفهرسة Indexing: لكل مكان رتبة Rank من 1 للدولة إلى 30 للمبنى والنقطة، وNominatim يمر على الرتب بالترتيب، ويحسب لكل مكان المناطق التي تحتويه، فيعرف أن المحل في حي العليا داخل مدينة الرياض داخل منطقة الرياض، وهذه أطول خطوة في الاستيراد.
- البحث Search: عند كل طلب يوحد شكل الكلمات Normalization، كما يشرح توثيق ال Tokenizer، ثم يجد الأماكن التي تحمل كل الكلمات، ويرتبها، ويعيد النتيجة JSON مع سطر الرخصة.
وهذا التقسيم يشرح أرقام متطلبات التثبيت: ذاكرة 2 جيجابايت على الأقل وإلا فشل التثبيت، أما العالم كله فيوصي التوثيق بذاكرة 128 جيجابايت أو أكثر ومساحة 1 تيرابايت على الأقل بأقراص NVMe، والاستيراد يأخذ قرابة يومين ونصف على جهاز جيد، وواقعياً أربعة إلى خمسة أيام بأقراص SSD عادية. ولهذا السبب فإن السؤال الصحيح ليس «هل أستضيف Nominatim؟» وإنما «ما المنطقة التي يحتاجها تطبيقي؟».
السيرفر العام: متى يكفي ومتى لا يكفي؟
مشروع OpenStreetMap يشغل سيرفراً عاماً لـ Nominatim، وسياسة استخدامه واضحة:
- طلب واحد في الثانية كحد أقصى مطلق، ولا يسمح بالاستخدام الثقيل.
- يجب أن ترسل ترويسة User-Agent أو Referer تعرف تطبيقك، والقيمة الافتراضية لمكتبات HTTP لا تقبل.
- يجب أن تخزن النتائج عندك Cache، والكلاينت الذي يكرر السؤال نفسه قد يعتبر معطوباً ويحجب.
- السكربتات التي تعمل أكثر من يوم أو تعمل بشكل دوري محدودة بأربعة طلبات في الدقيقة.
- الإكمال التلقائي Autocomplete أثناء الكتابة ممنوع.
- يجب أن تعرض نسبة البيانات Attribution، والتعريف بالاستخدام الثقيل قد يتغير ويمكن أن يسحب منك الوصول.
لذلك يكفيك السيرفر العام لتجربة أو لأداة داخلية تبحث عن عناوين قليلة يكتبها إنسان، أما إذا كان البحث جزءاً من تطبيقك، أو عندك ملف آلاف العناوين تريد تحويله دفعة واحدة Batch Geocoding، أو لا تريد أن تخرج عناوين عملائك من شبكتك، فأنت تحتاج سيرفرك الخاص.
التجربة العملية: Nominatim لمدينة الرياض
سوف نستورد مستطيل الرياض نفسه الذي استخدمناه في مقال الخرائط، حتى تكون الخريطة والبحث على البيانات نفسها، ونقيس الوقت والذاكرة على جهازنا، وهي أرقام لهذا الجهاز وهذه البيانات فقط وليست وعداً لجهازك.
تجهيز البيانات
شركة Geofabrik تنشر مقتطفات OpenStreetMap حسب المناطق، والسعودية فيها ضمن ملف دول مجلس التعاون الخليجي، والصفحة تذكر أن الرخصة ODbL 1.0، وأن الملفات لا تحتوي أسماء المستخدمين ومعرفاتهم بسبب قوانين حماية البيانات الأوروبية:
mkdir -p nominatim-lab/data && cd nominatim-lab
curl -fsSLO https://download.geofabrik.de/asia/gcc-states-latest.osm.pbf
curl -fsSLO https://download.geofabrik.de/asia/gcc-states-latest.osm.pbf.md5
md5sum -c gcc-states-latest.osm.pbf.md5
ls -l gcc-states-latest.osm.pbf | awk '{print $5, $9}'والمخرج سوف يكون كما يلي:
gcc-states-latest.osm.pbf: OK
254703997 gcc-states-latest.osm.pbfالملف 255 ميغابايت لست دول، ونحن نحتاج الرياض فقط، فنقصها بأداة osmium من Container مؤقت على Debian:
docker run --rm --name lab-maps-osmium -v "$PWD:/osm" debian:13.1 bash -c "
apt-get update -qq >/dev/null && apt-get install -y -qq osmium-tool >/dev/null 2>&1 &&
osmium --version | head -1 &&
time osmium extract --bbox 46.45,24.45,47.05,25.00 --strategy smart -S types=multipolygon,boundary \
/osm/gcc-states-latest.osm.pbf -o /osm/data/riyadh.osm.pbf --overwrite &&
osmium fileinfo -e /osm/data/riyadh.osm.pbf | grep -E 'Number of (nodes|ways|relations)'"
ls -l data/riyadh.osm.pbf | awk '{print $5, $9}'والمخرج سوف يكون كما يلي:
osmium version 1.18.0
real 0m30.481s
user 0m27.053s
sys 0m7.650s
Number of nodes: 1000654
Number of ways: 217148
Number of relations: 681
9555328 data/riyadh.osm.pbfفي الإعداد أعلاه لاحظ التالي:
- المستطيل هو نفسه في مقال الخرائط، بالترتيب: الغرب، الجنوب، الشرق، الشمال.
- الاستراتيجية
smartكما في توثيق osmium extract تكمل الطرق والعلاقات التي تقطع حدود المستطيل، وأضفناboundaryإلى أنواعها حتى تأتي الحدود الإدارية كاملة، لأن Nominatim يبني عنوان كل مكان منها، فإذا قطعت حدود منطقة الرياض في منتصفها فسوف تخرج العناوين ناقصة. - النتيجة ملف 9.6 ميغابايت فيه مليون نقطة تقريباً و217 ألف طريق ومضلع، ونحذف ملف الخليج بعد ذلك إذا لم نعد نحتاجه.
ملف التشغيل
نستخدم Docker Image من مشروع nominatim-docker، وهي Image واحدة فيها Nominatim وPostgreSQL معاً، ومثبتة على الإصدار 5.3.2، وخياراتها في دليل الاستخدام. والملف compose.yaml:
name: lab-maps-nominatim
services:
nominatim:
image: mediagis/nominatim:5.3.2
container_name: lab-maps-nominatim
shm_size: 1g
environment:
PBF_PATH: /nominatim/data/riyadh.osm.pbf
IMPORT_STYLE: full
THREADS: 4
NOMINATIM_PASSWORD: change-me-lab-only
POSTGRES_SHARED_BUFFERS: 1GB
POSTGRES_MAINTENANCE_WORK_MEM: 2GB
POSTGRES_EFFECTIVE_CACHE_SIZE: 4GB
volumes:
- ./data:/nominatim/data
- lab-maps-nominatim-data:/var/lib/postgresql/16/main
ports:
- "127.0.0.1:18710:8080"
volumes:
lab-maps-nominatim-data:
name: lab-maps-nominatim-dataفي الإعداد أعلاه لاحظ التالي:
PBF_PATHيستورد الملف المحلي، والبديلPBF_URLيحمله بنفسه، ولا يجتمعان.IMPORT_STYLE: fullهو الافتراضي ويستورد العناوين ونقاط الاهتمام Points of Interest مثل المستشفيات والمحلات، والأنماط الأصغرadminوstreetوaddressتوفر المساحة إذا كنت تحتاج المدن أو الشوارع فقط.- القيم الافتراضية لذاكرة PostgreSQL في هذه ال Image كبيرة، منها
POSTGRES_MAINTENANCE_WORK_MEMبقيمة 10GB وPOSTGRES_EFFECTIVE_CACHE_SIZEبقيمة 24GB كما في دليل الاستخدام، لذلك خفضناها إلى ما يناسب مدينة واحدة على سيرفر صغير. - المنفذ 8080 هو واجهة API، وربطناه ب 127.0.0.1 فقط، ولم ننشر منفذ PostgreSQL 5432 إطلاقاً، وغير
NOMINATIM_PASSWORDقبل أي استخدام حقيقي. - ال Volume على مجلد قاعدة البيانات يحفظ الاستيراد، فإذا أعدت إنشاء ال Container فلن يستورد من جديد.
الاستيراد: الوقت والذاكرة
نشغل ال Container بسكربت صغير measure.sh يسجل استهلاك الذاكرة من docker stats كل بضع ثوان حتى يجيب API:
#!/bin/bash
cd "$(dirname "$0")"
log=measure.log; : > $log
start=$(date +%s); echo "start $(date -u +%T)" >> $log
docker compose up -d >/dev/null 2>&1
until curl -sf "http://127.0.0.1:18710/status?format=json" >/dev/null 2>&1; do
m=$(docker stats --no-stream --format '{{.MemUsage}}' lab-maps-nominatim | awk '{print $1}')
echo "$(date -u +%T) $m" >> $log
sleep 2
done
end=$(date +%s); echo "ready $(date -u +%T) seconds=$((end-start))" >> $logثم نقرأ السجل بعد أن ينتهي:
chmod +x measure.sh && ./measure.sh
head -1 measure.log; tail -1 measure.log; grep -c MiB measure.log
awk 'NF==2 && $2 ~ /MiB$/ {n=$2+0; if (n>m) {m=n; t=$1}} END {print "max", m, "MiB at", t}' measure.logوالمخرج سوف يكون كما يلي:
start 20:27:07
ready 20:33:43 seconds=396
103
max 703.9 MiB at 20:30:36أي أن الاستيراد كاملاً أخذ 6 دقائق و36 ثانية، وأعلى استهلاك للذاكرة في 103 قراءات هو 703.9 ميغابايت أثناء الفهرسة، وقد يكون الأعلى الحقيقي أكبر قليلاً بين قراءتين. وفي سجلات ال Container تفاصيل المراحل:
docker logs lab-maps-nominatim 2>&1 | grep -E "Importing OSM data file|osm2pgsql took|Starting indexing rank \(1|FINISHED rank 26|Warming database caches|Application startup complete" | head -62026-10-08 20:27:21: Importing OSM data file
2026-10-08 20:27:52 osm2pgsql took 31s overall.
2026-10-08 20:28:12: Starting indexing rank (1 to 4) using 4 threads
2026-10-08 20:31:05: Done 79390/79394 in 157 @ 505.059 per second - FINISHED rank 26
2026-10-08 20:32:39: Warming database caches
[2026-10-08 20:33:43 +0000] [365] [INFO] Application startup complete.في المخرج أعلاه لاحظ التالي:
- osm2pgsql أخذ 31 ثانية فقط، والوقت الحقيقي في الفهرسة التي بدأت 20:28:12 وانتهت قبل 20:32:39، أي قرابة أربع دقائق ونصف، منها دقيقتان ونصف للرتبة 26 وحدها، وهي رتبة الشوارع، وعددها في الرياض 79,390.
- بعد الفهرسة يسخن Nominatim ذاكرة قاعدة البيانات Warming دقيقة تقريباً، ثم يبدأ API.
وفي السجل نفسه تحذير مهم:
Wikipedia/Wikidata importance tables missing.
Quality of search results may be degraded. Reverse geocoding is unaffected.
See https://nominatim.org/release-docs/latest/admin/Import/#wikipediawikidata-rankingsوالسبب أننا لم نستورد بيانات الأهمية من ويكيبيديا، وهي التي تجعل «الرياض» المدينة تظهر قبل مطعم اسمه الرياض، وتفعيلها بالمتغير IMPORT_WIKIPEDIA كما في توثيق الاستيراد، وسوف نرى أثر غيابها في ترتيب بعض النتائج. ثم نتحقق من الحجم وعدد الأماكن:
docker exec lab-maps-nominatim du -sh /var/lib/postgresql/16/main
docker exec lab-maps-nominatim sudo -u postgres psql -d nominatim -tAc \
"select pg_size_pretty(pg_database_size('nominatim')), (select count(*) from placex), (select count(*) from placex where housenumber is not null)"والمخرج سوف يكون كما يلي:
1.1G /var/lib/postgresql/16/main
432 MB|100378|435قاعدة البيانات 432 ميغابايت ومجلد PostgreSQL كاملاً 1.1 جيجابايت من ملف حجمه 9.6 ميغابايت، أي أن المساحة على القرص أكثر من مئة ضعف حجم الملف المضغوط، فخطط للقرص على هذا الأساس. ولاحظ الرقم الأخير: من 100,378 مكاناً في الرياض هناك 435 فقط عليها رقم مبنى Housenumber في OpenStreetMap، لذلك لا تتوقع أن يجد Nominatim عنواناً بمستوى رقم المبنى في الرياض، وإنما الشارع والحي والمكان المعروف.
البحث بالعربية والإنجليزية
نبدأ بحالة الخدمة:
curl -s "http://127.0.0.1:18710/status?format=json"{"status":0,"message":"OK","data_updated":"2026-10-07T10:45:17+00:00","software_version":"5.3.2","database_version":"5.3.2-0"}ثم أول بحث بالعربية، ونطلب نتيجة واحدة بصيغة jsonv2:
curl -s -G http://127.0.0.1:18710/search --data-urlencode "q=حديقة الملك سلمان" -d format=jsonv2 -d limit=1 \
| python3 -m json.tool --no-ensure-ascii --indent 2والمخرج سوف يكون كما يلي:
[
{
"place_id": 140558,
"licence": "Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright",
"osm_type": "relation",
"osm_id": 13254453,
"lat": "24.7219400",
"lon": "46.7173087",
"category": "boundary",
"type": "administrative",
"place_rank": 20,
"importance": 0.1333433333333333,
"addresstype": "neighbourhood",
"name": "حديقة الملك سلمان",
"display_name": "حديقة الملك سلمان, محافظة الرياض, منطقة الرياض, السعودية",
"boundingbox": [
"24.6935546",
"24.7496418",
"46.6929688",
"46.7465649"
]
}
]في المخرج أعلاه لاحظ التالي:
--data-urlencodeيرمز النص العربي في الرابط، ولا تكتب الحروف العربية في الرابط مباشرة.- النتيجة الأولى حد إداري
boundaryباسم حديقة الملك سلمان من رتبة الحي، وليست الحديقة نفسها، فالبيانات فيها الاثنان، وسوف تظهر الحديقةleisure/parkفي البحث التالي. - سطر
licenceيذكرك أن النتائج من بيانات ODbL، فإذا عرضتها في تطبيقك فاعرض النسبة كما في مقال الخرائط.
ولتسهيل قراءة بقية النتائج نعرف دالة صغيرة تطبع النوع والعنوان فقط:
names() { python3 -c 'import json,sys; [print(r["type"], "|", r["display_name"]) for r in json.load(sys.stdin)]'; }
curl -s -G http://127.0.0.1:18710/search --data-urlencode "q=King Salman Park" -d format=jsonv2 | names
curl -s -G http://127.0.0.1:18710/search --data-urlencode "q=King Salman Park" -d format=jsonv2 -d accept-language=en | names
curl -s -G http://127.0.0.1:18710/search --data-urlencode "q=مطار الملك خالد الدولي" -d format=jsonv2 -d accept-language=en | namesوالمخرج سوف يكون كما يلي:
park | حديقة الملك سلمان, الرياض, محافظة الرياض, منطقة الرياض, 11461, السعودية
park | منتزة الملك سلمان البري, محافظة الرياض, منطقة الرياض, السعودية
park | King Salman Park, Riyadh, Riyadh governorate, Riyadh Region, 11461, Saudi Arabia
park | King Salman Park, Riyadh governorate, Riyadh Region, Saudi Arabia
aerodrome | King Khalid International Airport, Riyadh governorate, Riyadh Region, Saudi Arabiaوهنا نقطة تحير كثيراً من المطورين: بحثنا بالإنجليزية فجاءت النتائج بالعربية، والسبب كما في توثيق البحث أن لغة النتيجة تحددها ترويسة Accept-Language أو المعامل accept-language، وأدوات سطر الأوامر مثل curl لا ترسل الترويسة، فيعرض Nominatim الاسم المحلي، وهو العربي في الرياض. أما المتصفح فيرسلها تلقائياً بلغة جهاز المستخدم، لذلك حدد اللغة صراحة في تطبيقك. ولاحظ أن البحث يقبل لغة ويعيد بلغة أخرى، فالسؤال بالعربية عن المطار أعاد العنوان بالإنجليزية.
ما يجيده بالعربية وما يفوته
curl -s -G http://127.0.0.1:18710/search --data-urlencode "q=برج المملكة" -d format=jsonv2 | names
curl -s -G http://127.0.0.1:18710/search --data-urlencode "q=مركز المملكة" -d format=jsonv2 | names
curl -s -G http://127.0.0.1:18710/search --data-urlencode "q=طريق الأمير محمد بن عبدالعزيز" -d format=jsonv2 -d limit=2 | names
curl -s -G http://127.0.0.1:18710/search --data-urlencode "q=حديقه الملك سلمان" -d format=jsonv2 | namesوالمخرج سوف يكون كما يلي:
mall | مركز المملكة, سيدة الرؤساء, الرياض, محافظة الرياض, منطقة الرياض, 12251, السعودية
commercial | مركز المملكة, رقم 94, العليا, الرياض, محافظة الرياض, منطقة الرياض, 12244, السعودية
restaurant | Macboos Restaurant, التحليه مع الملك فهد, طريق الامير محمد بن عبدالعزيز, Riyadh Saudi Arabia, طريق الملك فهد, بلدية المعذر, الرياض, محافظة الرياض, منطقة الرياض, 12313, السعودية
restaurant | TOPKAPI Tahlia, التحليه مع الملك فهد, طريق الامير محمد بن عبدالعزيز, Riyadh Saudi Arabia, طريق الملك فهد, بلدية المعذر, الرياض, محافظة الرياض, منطقة الرياض, 12313, السعوديةأربعة أسئلة وأربع نتائج مختلفة، وكل واحدة تعلمك شيئاً:
- «برج المملكة» لم يعد شيئاً: لأن الاسم في البيانات «مركز المملكة»، وNominatim يبحث في الأسماء الموجودة في OpenStreetMap ومنها الأسماء البديلة
alt_nameإن وجدت، ولا يعرف الأسماء الدارجة التي يستخدمها الناس، والحل في البيانات لا في الكود. - الهمزة لا تهم: كتبنا «الأمير» بالهمزة، والاسم في البيانات «الامير» دونها، ومع ذلك وجده، لأن Nominatim يوحد أشكال الألف قبل البحث.
- الترتيب ليس دائماً ما تتوقع: سألنا عن الطريق فجاء مطعمان على الطريق قبله، وهذا من أثر غياب بيانات الأهمية من ويكيبيديا، ومن عناوين أدخلها مستخدمون بصيغة غير مرتبة مثل «Riyadh Saudi Arabia» داخل حقل الشارع.
- التاء المربوطة والهاء مختلفتان: «حديقه» بالهاء لم تعد شيئاً، مع أنها من أشهر أخطاء الكتابة عند المستخدمين، فإذا كان تطبيقك يعتمد على ما يكتبه الناس بسرعة فعالج هذا في تطبيقك، مثلاً بإعادة السؤال بالتاء المربوطة إذا لم تأت نتيجة.
وللعناوين المنظمة، مثل نموذج فيه حقل للشارع وحقل للمدينة، يوجد البحث المنظم Structured Search، وهو لا يجتمع مع q كما في التوثيق:
curl -s -G http://127.0.0.1:18710/search --data-urlencode "street=طريق العليا" --data-urlencode "city=الرياض" \
-d format=jsonv2 -d limit=3 | namessecondary | مدخل طريق العليا, الملك فهد, الرياض, محافظة الرياض, منطقة الرياض, 12361, السعودية
tertiary | طريق العليا العام, السلامة, العليا, الرياض, محافظة الرياض, منطقة الرياض, 12212, السعودية
secondary | مدخل طريق العليا, الورود, الرياض, محافظة الرياض, منطقة الرياض, 12361, السعوديةالبحث العكسي
نعطيه إحداثيات مركز المملكة ونطلب تفاصيل العنوان:
curl -s -G http://127.0.0.1:18710/reverse -d lat=24.7114 -d lon=46.6744 -d format=jsonv2 -d addressdetails=1 \
| python3 -m json.tool --no-ensure-ascii --indent 2والمخرج سوف يكون كما يلي:
{
"place_id": 145026,
"licence": "Data © OpenStreetMap contributors, ODbL 1.0. http://osm.org/copyright",
"osm_type": "node",
"osm_id": 6535480762,
"lat": "24.7111809",
"lon": "46.6737654",
"category": "office",
"type": "company",
"place_rank": 30,
"importance": 9.99999999995449e-06,
"addresstype": "office",
"name": "McKinsey & Company",
"display_name": "McKinsey & Company, طريق الملك فهد الفرعي, الورود, الرياض, محافظة الرياض, منطقة الرياض, 12215, السعودية",
"address": {
"office": "McKinsey & Company",
"road": "طريق الملك فهد الفرعي",
"suburb": "الورود",
"city": "الرياض",
"province": "محافظة الرياض",
"state": "منطقة الرياض",
"ISO3166-2-lvl4": "SA-01",
"postcode": "12215",
"country": "السعودية",
"country_code": "sa"
},
"boundingbox": [
"24.7111309",
"24.7112309",
"46.6737154",
"46.6738154"
]
}النقطة داخل مركز المملكة، والنتيجة مكتب شركة داخل المبنى، لأن البحث العكسي يعيد أقرب جسم في البيانات، وهو هنا نقطة المكتب وليس المبنى. والحقل address هو الأهم لتطبيقك، فيه الشارع والحي والمدينة والرمز البريدي كل في حقل مستقل، فلا تقسم display_name بالفواصل. وإذا كنت تحتاج الحي أو المدينة فقط فاستخدم المعامل zoom:
for z in 18 14 10; do
curl -s -G http://127.0.0.1:18710/reverse -d lat=24.7114 -d lon=46.6744 -d zoom=$z -d format=jsonv2 -d accept-language=en \
| python3 -c 'import json,sys; r=json.load(sys.stdin); print(r["addresstype"], "|", r["display_name"])'
doneoffice | McKinsey & Company, King Fahad Branch Road, Al Wuroud District, Riyadh, Riyadh governorate, Riyadh Region, 12215, Saudi Arabia
suburb | Al `Ulayya, Riyadh, Riyadh governorate, Riyadh Region, 12212, Saudi Arabia
city | Riyadh, Riyadh governorate, Riyadh Region, 12634, Saudi Arabiaوجدول توثيق البحث العكسي يحدد المعنى: 18 للمبنى، و14 للحي Neighbourhood، و10 للمدينة، و3 للدولة. ولاحظ أن المبنى في حي الورود والحي في المستوى 14 هو العليا، لأن النقطة على حدود الحيين، وهذا ما قصده التوثيق بالنتائج غير المتوقعة في المدن المزدحمة. وفي الطلبات المتكررة على السيرفر نفسه كان متوسط زمن 50 طلب بحث عكسي 33.5 مللي ثانية وأقصاها 90 مللي ثانية، وهي أرقام توضيحية على جهاز المختبر.
البحث على الخريطة
الآن نربط Nominatim بخريطة المقال السابق، فنضيف صفحة search.html إلى مجلد web نفسه، فيها حقل بحث ونقرة على الخريطة للبحث العكسي:
<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>البحث عن عنوان</title>
<link rel="stylesheet" href="/lib/maplibre-gl.css">
<style>
html, body, #map { margin: 0; height: 100%; font-family: system-ui, sans-serif; }
form { position: absolute; top: 12px; right: 12px; z-index: 1; display: flex; gap: 6px; background: #fff; padding: 8px; border-radius: 10px; box-shadow: 0 2px 8px #0003; }
input { width: 300px; font-size: 16px; padding: 6px 10px; }
.maplibregl-popup-content { font-size: 14px; max-width: 320px; }
</style>
<script type="importmap">{ "imports": { "fflate": "/lib/fflate.mjs" } }</script>
</head>
<body>
<form id="f"><input id="q" placeholder="ابحث عن مكان أو عنوان" autocomplete="off"><button>بحث</button></form>
<div id="map"></div>
<script type="module">
import * as maplibregl from '/lib/maplibre-gl.mjs';
import { Protocol } from '/lib/pmtiles.mjs';
import { layers, namedFlavor } from '/lib/basemaps.mjs';
const NOMINATIM = 'http://127.0.0.1:18710';
maplibregl.addProtocol('pmtiles', new Protocol().tile);
const map = window.map = new maplibregl.Map({
container: 'map', center: [46.6753, 24.7136], zoom: 12,
style: { version: 8, glyphs: '/assets/fonts/{fontstack}/{range}.pbf', sprite: `${location.origin}/assets/sprites/v4/light`,
sources: { protomaps: { type: 'vector', url: 'pmtiles:///tiles/riyadh.pmtiles',
attribution: '<a href="https://www.openstreetmap.org/copyright">© OpenStreetMap</a>' } },
layers: layers('protomaps', namedFlavor('light'), { lang: 'ar' }) }
});
const marker = new maplibregl.Marker({ color: '#E95420' });
const show = (lon, lat, text) => {
marker.setLngLat([lon, lat]).setPopup(new maplibregl.Popup({ offset: 30 }).setText(text)).addTo(map).togglePopup();
};
// search: text -> coordinates (search is sent only on submit, never on every key press)
document.getElementById('f').addEventListener('submit', async (e) => {
e.preventDefault();
const q = document.getElementById('q').value.trim();
if (!q) return;
const r = await fetch(`${NOMINATIM}/search?` + new URLSearchParams({ q, format: 'jsonv2', limit: 1, 'accept-language': 'ar' }));
const [hit] = await r.json();
if (!hit) return alert('لا توجد نتيجة');
map.flyTo({ center: [+hit.lon, +hit.lat], zoom: 16, duration: 0 });
show(+hit.lon, +hit.lat, hit.display_name);
});
// reverse: a click on the map -> the nearest address
map.on('click', async (e) => {
const { lng, lat } = e.lngLat;
const r = await fetch(`${NOMINATIM}/reverse?` + new URLSearchParams({ lat, lon: lng, format: 'jsonv2', zoom: 18, 'accept-language': 'ar' }));
const hit = await r.json();
show(lng, lat, hit.display_name || 'لا يوجد عنوان هنا');
});
</script>
</body>
</html>في الإعداد أعلاه لاحظ التالي:
- الصفحة على المنفذ 18701 وNominatim على 18710، أي على أصلين Origins مختلفين، وهذا يعمل لأن Nominatim يرد بالترويسة
access-control-allow-origin: *، وفي الإنتاج ضعهما خلف دومين واحد بمسارين عبر Reverse Proxy. - البحث يرسل عند الضغط على الزر فقط وليس مع كل حرف، فإذا أردت إكمالاً تلقائياً أثناء الكتابة فهذا ليس عمل Nominatim، وسوف نعود إليه بعد قليل.
- نحدد
accept-languageصراحة حتى لا تتغير لغة النتائج بحسب جهاز المستخدم.
والصورة التالية بعد البحث عن «مستشفى الملك فيصل التخصصي»:

وهذه بعد نقرة على مركز المملكة، وفيها ما رأيناه في سطر الأوامر: أقرب جسم هو المكتب لا المبنى:

نكسر الإعداد: سؤال خارج المنطقة
ماذا يحدث إذا سأل المستخدم عن مكان خارج المنطقة التي استوردتها؟
curl -s -G http://127.0.0.1:18710/search --data-urlencode "q=كورنيش جدة" -d format=jsonv2; echo
curl -s -G http://127.0.0.1:18710/reverse -d lat=21.5433 -d lon=39.1728 -d format=jsonv2 \
| python3 -c 'import json,sys; r=json.load(sys.stdin); print(r["addresstype"], "|", r["display_name"])'والمخرج سوف يكون كما يلي:
[]
country | السعوديةالبحث أعاد قائمة فارغة، وهذا متوقع، ولكن انتبه للسطر الثاني: نقطة في جدة أعادت «السعودية» دون أي خطأ، والسبب أن حدود الدولة كاملة في ملفنا بفضل استراتيجية smart، فالنقطة داخل الدولة ولا يوجد حولها أي شيء آخر. وإذا كان تطبيقك يخزن هذه النتيجة كعنوان العميل فسوف تجد في قاعدة بياناتك عناوين اسمها «السعودية» فقط، لذلك تحقق من addresstype أو place_rank في الرد، وارفض النتيجة إذا كانت أعم مما تحتاج.
من المختبر إلى الإنتاج
- المنطقة: ابدأ من المنطقة التي يحتاجها تطبيقك، مدينة أو دولة، فالذاكرة والقرص ووقت الاستيراد تكبر معها، والعالم كله مشروع مختلف تماماً كما رأينا في المتطلبات.
- التحديث: Geofabrik تنشر تحديثات يومية لملف الخليج في gcc-states-updates، وال Image تقبلها بالمتغير
REPLICATION_URLوUPDATE_MODE، ولكن التحديثات تخص المنطقة كاملة، فإذا قصصت مستطيلاً كما فعلنا فالأبسط أن تعيد الاستيراد دورياً، أو أن تستورد ملف المنطقة كاملاً وتحدثه. - جودة الترتيب: فعل
IMPORT_WIKIPEDIAفي الإنتاج، فهو يحسن ترتيب الأماكن المعروفة، ولا يؤثر في البحث العكسي كما يقول التحذير نفسه. - الإكمال التلقائي: Nominatim لا يدعم البحث أثناء الكتابة، والسيرفر العام يمنعه صراحة، وإذا احتجته فمشروع Photon بترخيص Apache 2.0 مبني لهذا الغرض ويستورد بياناته من Nominatim.
- الأمان: لا تنشر المنفذ 5432، وضع API خلف Reverse Proxy بحد لعدد الطلبات Rate Limiting، والسبب أن كل سؤال ثقيل على قاعدة البيانات، وسكربت واحد يمكن أن يشغل السيرفر كله.
- النسخ الاحتياطي: لا تنسخ قاعدة Nominatim كما تنسخ بيانات تطبيقك، لأنها تبنى من جديد من ملف OSM، واحفظ بدلاً منها ملف
compose.yamlوتاريخ البيانات التي استوردتها. - النسبة: النتائج من بيانات ODbL، فاعرض نسبة OpenStreetMap حيث تعرض النتائج، وإرشادات النسبة تقول إن مجموعة نتائج البحث لا يلزم أن تحمل النسبة مع كل نتيجة ما لم تشكل قاعدة بيانات مشتقة.
تنظيف المختبر
من داخل مجلد المختبر نحذف ال Container والشبكة وال Volume بالاسم، ثم Docker Image وملفات البيانات:
docker compose down -v
docker image rm mediagis/nominatim:5.3.2
rm -f gcc-states-latest.osm.pbf gcc-states-latest.osm.pbf.md5 data/riyadh.osm.pbfوالمخرج سوف يكون كما يلي:
Container lab-maps-nominatim Stopping
Container lab-maps-nominatim Stopped
Container lab-maps-nominatim Removing
Container lab-maps-nominatim Removed
Volume lab-maps-nominatim-data Removing
Network lab-maps-nominatim_default Removing
Volume lab-maps-nominatim-data Removed
Network lab-maps-nominatim_default Removed
Untagged: mediagis/nominatim:5.3.2
Deleted: sha256:7923a8e67197fc6d4f4ecb7c0e8bbedffeddcfdf4519596fe946e46a28f5a9f8تدريب عملي
- استورد مدينتك بدل الرياض، وقارن وقت الاستيراد وحجم قاعدة البيانات وعدد الأماكن التي عليها رقم مبنى بما رأيناه هنا.
- أضف إلى صفحة البحث محاولة ثانية تلقائية تستبدل الهاء في آخر الكلمة بتاء مربوطة إذا لم تأت نتيجة، واختبرها بكلمة «حديقه».
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- البحث عن العناوين يحول النص إلى إحداثيات، والبحث العكسي يعيد أقرب جسم في البيانات لا عنواناً مخترعاً.
- السيرفر العام لـ Nominatim محدود بطلب واحد في الثانية، ويمنع الإكمال التلقائي ويشترط التخزين المؤقت، فإذا كان البحث جزءاً من تطبيقك فاستضفه بنفسك.
- Nominatim يستورد مرة واحدة ويبني العناوين في PostgreSQL مع PostGIS، وفي المختبر أخذت الرياض 6 دقائق و36 ثانية، بأعلى ذاكرة 704 ميغابايت، وقاعدة 432 ميغابايت من ملف 9.6 ميغابايت.
- بالعربية يوحد أشكال الألف، ولكنه لا يعرف الأسماء الدارجة غير الموجودة في البيانات، ولا يساوي بين التاء المربوطة والهاء، ورقم المبنى نادر في بيانات الرياض.
- حدد اللغة بالمعامل
accept-language، واقرأ الحقلaddressلاdisplay_name، وارفض النتائج الأعم مما تحتاج.
دليلخرائطك على سيرفرك مع OpenStreetMap وMapLibreكيف تعرض خريطة تفاعلية لمدينتك من سيرفرك ببيانات OpenStreetMap، بالعربية والإنجليزية ودون مفتاح API أو مزود خارجي.
دليلPostgreSQL لكل شيء: متى يكفيك ومتى تحتاج غيره؟كم عملاً يستطيع PostgreSQL واحد أن يقوم به في شركتك الناشئة قبل أن تحتاج Redis أو Elasticsearch، وما الإشارة التي تقول لك إن الوقت قد حان.
دليلكيف يرى تطبيقك عنوان الزائر الحقيقي خلف ال Reverse Proxyإذا كان تطبيقك يرى عنوان ال Proxy بدلاً من الزائر أو يولد روابط http، فهذا هو السبب والإعداد الصحيح لإطار عملك.
دليلتثبيت Docker Engine وCompose على Ubuntuكيف تثبت Docker على Ubuntu من المستودع الرسمي، وتضبطه من البداية حتى لا يمتلئ القرص أو تنكشف منافذك للإنترنت.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ المختبر على mediagis/nominatim:5.3.2 (Nominatim 5.3.2، osm2pgsql 1.11.0، PostgreSQL 16) ببيانات Geofabrik لدول الخليج بتاريخ 2026-10-07 مقصوصة بأداة osmium 1.18.0، وDocker Engine 29.8.0 وDocker Compose v5.5.1 على Windows 11 مع Docker Desktop.