From 993c34758f1c701a4095e68262f8bb7b54374e07 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Sat, 20 Jun 2026 15:31:27 +0330 Subject: [PATCH] Geocode neighborhood names to an approximate location (no source coords) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Many Medjobs/Telegram ads name a Tehran neighborhood («ونک», «تهرانپارس»…) but carry no coordinates. New TehranGeo geocoder maps ~45 neighborhood names to a rough center; Publish falls back to it (from the resolved district / AI district / area note) when the source ad has no point. Shown via the existing «محدودهٔ تقریبی» circle + disclaimer — never a precise pin. Tehran-only; extends the existing approx-coords feature so non-Divar listings can show a map too. Co-Authored-By: Claude Opus 4.8 --- .../Services/Scraping/IngestionService.cs | 14 +++-- .../Services/Scraping/TehranGeo.cs | 52 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 src/JobsMedical.Web/Services/Scraping/TehranGeo.cs diff --git a/src/JobsMedical.Web/Services/Scraping/IngestionService.cs b/src/JobsMedical.Web/Services/Scraping/IngestionService.cs index 1b09a13..98b1a68 100644 --- a/src/JobsMedical.Web/Services/Scraping/IngestionService.cs +++ b/src/JobsMedical.Web/Services/Scraping/IngestionService.cs @@ -307,6 +307,14 @@ public class IngestionService ?? cities.FirstOrDefault(c => c.IsActive) ?? cities.First(); var district = districts.FirstOrDefault(x => x.Name == districtName && x.CityId == city.Id); + // Approx. coords for the map: the source ad's point (Divar) when present; otherwise, for a + // Tehran ad that only NAMES a neighborhood (Medjobs/Telegram), geocode that name to a rough + // center. Shown as a «محدودهٔ تقریبی» circle, never a precise pin. + double? appLat = raw.Lat, appLng = raw.Lng; + if (appLat is null && city.Name == "تهران" + && TehranGeo.Locate(district?.Name, districtName, parsed.AreaNote) is { } g) + { appLat = g.lat; appLng = g.lng; } + var kindStr = (d?.Kind ?? parsed.Kind.ToString()).ToLowerInvariant(); // «آماده به کار» — a worker offering themselves. No facility involved. @@ -334,7 +342,7 @@ public class IngestionService Phone = !string.IsNullOrWhiteSpace(d?.Phone) ? d!.Phone!.Trim() : parsed.Phone, Description = raw.RawText, Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl, - Lat = raw.Lat, Lng = raw.Lng, // approx. area from the source ad (Divar) + Lat = appLat, Lng = appLng, // source point (Divar) or geocoded neighborhood center Contacts = BuildContacts(d, parsed), Tags = BuildTags(parsed, d, role, city, extraRoleTags), }); @@ -382,7 +390,7 @@ public class IngestionService SalaryMin = parsed.PayAmount, Description = raw.RawText, Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl, - Lat = raw.Lat, Lng = raw.Lng, // approx. area from the source ad (Divar) + Lat = appLat, Lng = appLng, // source point (Divar) or geocoded neighborhood center Contacts = BuildContacts(d, parsed), // the ad's OWN number(s) — fresh per listing }); } @@ -401,7 +409,7 @@ public class IngestionService : parsed.PayAmount is null ? PayType.Negotiable : PayType.PerShift, PayAmount = parsed.PayAmount, SharePercent = parsed.SharePercent, Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl, - Lat = raw.Lat, Lng = raw.Lng, // approx. area from the source ad (Divar) + Lat = appLat, Lng = appLng, // source point (Divar) or geocoded neighborhood center Contacts = BuildContacts(d, parsed), // the ad's OWN number(s) — fresh per listing }); } diff --git a/src/JobsMedical.Web/Services/Scraping/TehranGeo.cs b/src/JobsMedical.Web/Services/Scraping/TehranGeo.cs new file mode 100644 index 0000000..2f24289 --- /dev/null +++ b/src/JobsMedical.Web/Services/Scraping/TehranGeo.cs @@ -0,0 +1,52 @@ +using System.Text.RegularExpressions; + +namespace JobsMedical.Web.Services.Scraping; + +/// +/// Coarse neighborhood → APPROXIMATE center geocoder for Tehran. Many ads (Medjobs/Telegram) name a +/// neighborhood but carry no coordinates; this lets us show an approximate-area circle from the name +/// alone. Centers are deliberately rough (the UI always labels them «محدودهٔ تقریبی»), never an +/// address. Extend the table freely — order doesn't matter, matching is name-normalized + substring. +/// +public static class TehranGeo +{ + private static readonly (string Name, double Lat, double Lng)[] Raw = + { + ("سعادت‌آباد", 35.7872, 51.3760), ("شهرک غرب", 35.7570, 51.3680), ("نارمک", 35.7448, 51.5085), + ("تهرانپارس", 35.7350, 51.5400), ("ونک", 35.7560, 51.4100), ("تجریش", 35.8040, 51.4340), + ("ولیعصر", 35.7986, 51.4087), ("پارک‌وی", 35.7986, 51.4087), ("گیشا", 35.7400, 51.3880), + ("برج میلاد", 35.7448, 51.3753), ("پاسداران", 35.7890, 51.4560), ("میرداماد", 35.7600, 51.4300), + ("جردن", 35.7700, 51.4180), ("آفریقا", 35.7700, 51.4180), ("ولنجک", 35.8080, 51.4080), + ("نیاوران", 35.8170, 51.4700), ("زعفرانیه", 35.8100, 51.4200), ("الهیه", 35.7900, 51.4320), + ("قیطریه", 35.7950, 51.4450), ("فرمانیه", 35.8000, 51.4700), ("دروس", 35.7850, 51.4500), + ("یوسف‌آباد", 35.7370, 51.4050), ("امیرآباد", 35.7260, 51.3920), ("انقلاب", 35.7010, 51.3940), + ("صادقیه", 35.7150, 51.3450), ("پونک", 35.7620, 51.3300), ("جنت‌آباد", 35.7600, 51.3100), + ("اکباتان", 35.7150, 51.3100), ("ستارخان", 35.7200, 51.3550), ("مرزداران", 35.7400, 51.3500), + ("نازی‌آباد", 35.6400, 51.4080), ("یافت‌آباد", 35.6600, 51.3500), ("شهرری", 35.5850, 51.4350), + ("پیروزی", 35.7000, 51.4800), ("رسالت", 35.7450, 51.5000), ("حکیمیه", 35.7450, 51.5800), + ("تهرانسر", 35.7100, 51.2500), ("شریعتی", 35.7600, 51.4400), ("سهروردی", 35.7300, 51.4300), + ("آزادی", 35.7000, 51.3600), ("جمهوری", 35.6960, 51.3920), ("هفت تیر", 35.7250, 51.4230), + ("ولیعصر پایین", 35.7100, 51.4070), ("نواب", 35.6850, 51.3750), ("سعدی", 35.6900, 51.4250), + }; + + // Built once: normalized name → center. Insertion order kept for the substring pass. + private static readonly List<(string Key, double Lat, double Lng)> Map = + Raw.Select(x => (Norm(x.Name), x.Lat, x.Lng)).ToList(); + + /// First of the given names that maps to a known Tehran neighborhood center (exact, then + /// substring — «میدان ونک» → «ونک»). Returns null when nothing matches. + public static (double lat, double lng)? Locate(params string?[] names) + { + foreach (var raw in names) + { + if (string.IsNullOrWhiteSpace(raw)) continue; + var n = Norm(raw); + foreach (var m in Map) if (m.Key == n) return (m.Lat, m.Lng); // exact + foreach (var m in Map) if (n.Contains(m.Key)) return (m.Lat, m.Lng); // «… ونک …» + } + return null; + } + + private static string Norm(string s) => Regex.Replace( + s.Replace('ي', 'ی').Replace('ك', 'ک').Replace('‌', ' ').Trim(), @"\s+", " ").ToLowerInvariant(); +}