الأدلة التقنية

البحث عن العناوين Geocoding مع Nominatim

كيف تحول اسم المكان إلى إحداثيات والإحداثيات إلى عنوان على سيرفرك مع Nominatim، وما الذي يجيده بالعربية وما الذي يفوته.

البحث عن العناوين Geocoding مع Nominatim

في مقال خرائطك على سيرفرك مع OpenStreetMap وMapLibre وضعنا خريطة الرياض كاملة على سيرفرنا، ولكن الخريطة وحدها لا تكفي أغلب التطبيقات، فالمستخدم يكتب «مستشفى الملك فيصل التخصصي» ويريد أن يراه على الخريطة، والمندوب يرسل إحداثيات GPS من هاتفه وتريد أن تعرف في أي حي هو. وهذه العملية اسمها البحث عن العناوين Geocoding، والطريقة البسيطة هي أن ترسل كل بحث إلى Google أو إلى السيرفر العام لمشروع OpenStreetMap، ولكن الأول يحسب عليك كل طلب ويرى كل عنوان يبحث عنه عملاؤك، والثاني له سياسة استخدام صارمة سوف نقرؤها بعد قليل.

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

  • ما هو البحث عن العناوين Geocoding والبحث العكسي Reverse Geocoding.
  • كيف يعمل Nominatim من الداخل، ولماذا يحتاج استيراداً طويلاً مرة واحدة.
  • سياسة استخدام السيرفر العام، ومتى تحتاج سيرفرك الخاص.
  • مختبر كامل على Docker: استيراد مستطيل الرياض، مع قياس الوقت والذاكرة، وأسئلة بالعربية والإنجليزية بمخرجاتها الحقيقية.
  • ما يجيده Nominatim بالعربية وما يفوته، وربطه بالخريطة من المقال السابق.

ما هو البحث عن العناوين Geocoding؟

البحث عن العناوين Geocoding هو تحويل نص يكتبه إنسان، اسم مكان أو عنوان، إلى إحداثيات خط العرض Latitude وخط الطول Longitude، والبحث العكسي Reverse Geocoding هو العكس: تعطيه نقطة فيعيد لك أقرب عنوان لها. والصورة التالية تبين الاتجاهين بإجابتين حقيقيتين من المختبر:

مخطط من صفين: الأول البحث، المستخدم يكتب مستشفى الملك فيصل التخصصي فيرد Nominatim بخط العرض 24.6707344 وخط الطول 46.6745034 من نوع amenity hospital لاستخدامها في علامة على الخريطة أو منطقة توصيل أو مسافة. والثاني البحث العكسي، نقطة 24.7114 و46.6744 من GPS الهاتف أو نقرة على الخريطة فيرد Nominatim بطريق الملك فهد الفرعي، الورود، الرياض، منطقة الرياض، 12215، السعودية، وهو أقرب جسم في OSM وهو مكتب داخل مركز المملكة، ومع zoom 18 مبنى و14 حي و10 مدينة
البحث يحول النص إلى نقطة، والبحث العكسي يحول النقطة إلى أقرب عنوان في البيانات

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

كيف يعمل Nominatim من الداخل؟

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

مخطط من صفين: عند الاستيراد، ملف riyadh.osm.pbf بحجم 9.6 ميغابايت ومليون نقطة يدخل osm2pgsql في 31 ثانية إلى PostgreSQL مع PostGIS في جدول placex فيه 100378 مكاناً بحجم 432 ميغابايت، ثم الفهرسة من الرتبة 1 إلى 30 تبني عنوان كل مكان من المناطق حوله، المكان داخل الحي داخل المدينة داخل الدولة، في قرابة أربع دقائق ونصف. وعند كل طلب، التطبيق يرسل search إلى Nominatim الذي يوحد شكل الكلمات ويجد الأماكن التي فيها كل الكلمات ويرتبها بالأهمية ونوع المكان، ثم يرد JSON فيه الإحداثيات والعنوان وسطر رخصة ODbL، في 25 إلى 35 مللي ثانية بعد تسخين الذاكرة
الاستيراد يبني العناوين مرة واحدة، والبحث بعد ذلك سؤال سريع لقاعدة البيانات
  1. الاستيراد Import: أداة osm2pgsql تقرأ ملف OpenStreetMap بصيغة PBF وتكتبه في PostgreSQL مع إضافة PostGIS، وهي الإضافة الجغرافية التي شرحناها في قسم PostGIS في مقال PostgreSQL لكل شيء.
  2. الفهرسة Indexing: لكل مكان رتبة Rank من 1 للدولة إلى 30 للمبنى والنقطة، وNominatim يمر على الرتب بالترتيب، ويحسب لكل مكان المناطق التي تحتويه، فيعرف أن المحل في حي العليا داخل مدينة الرياض داخل منطقة الرياض، وهذه أطول خطوة في الاستيراد.
  3. البحث 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 -6
2026-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 | names
secondary | مدخل طريق العليا, الملك فهد, الرياض, محافظة الرياض, منطقة الرياض, 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"])'
done
office | 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 صراحة حتى لا تتغير لغة النتائج بحسب جهاز المستخدم.

والصورة التالية بعد البحث عن «مستشفى الملك فيصل التخصصي»:

صفحة البحث وفي أعلاها حقل فيه مستشفى الملك فيصل التخصصي وزر بحث، والخريطة مقربة على مجمع المستشفى بين شارع الزهراوي وطريق التخصصي، وعلامة برتقالية فوقها نافذة فيها: مستشفى الملك فيصل التخصصي، الأحنف بن قيس، المؤتمرات، بلدية الشميسي، الرياض، محافظة الرياض، منطقة الرياض، 12713، السعودية
البحث بالعربية من سيرفرنا، والخريطة من سيرفرنا أيضاً

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

الخريطة مقربة على مركز المملكة بين طريق الملك فهد وشارع العليا، وعلامة برتقالية في وسطه فوقها نافذة: McKinsey & Company، طريق الملك فهد الفرعي، الورود، الرياض، محافظة الرياض، منطقة الرياض، 12215، السعودية
البحث العكسي بنقرة على الخريطة يعيد أقرب جسم في البيانات

نكسر الإعداد: سؤال خارج المنطقة

ماذا يحدث إذا سأل المستخدم عن مكان خارج المنطقة التي استوردتها؟

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.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.