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();
+}