Geocode neighborhood names to an approximate location (no source coords)
CI/CD / CI · dotnet build (push) Successful in 2m4s
CI/CD / Deploy · hamkadr (push) Successful in 1m54s

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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-20 15:31:27 +03:30
parent 4ab6ce29c9
commit 993c34758f
2 changed files with 63 additions and 3 deletions
@@ -307,6 +307,14 @@ public class IngestionService
?? cities.FirstOrDefault(c => c.IsActive) ?? cities.First(); ?? cities.FirstOrDefault(c => c.IsActive) ?? cities.First();
var district = districts.FirstOrDefault(x => x.Name == districtName && x.CityId == city.Id); 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(); var kindStr = (d?.Kind ?? parsed.Kind.ToString()).ToLowerInvariant();
// «آماده به کار» — a worker offering themselves. No facility involved. // «آماده به کار» — a worker offering themselves. No facility involved.
@@ -334,7 +342,7 @@ public class IngestionService
Phone = !string.IsNullOrWhiteSpace(d?.Phone) ? d!.Phone!.Trim() : parsed.Phone, Phone = !string.IsNullOrWhiteSpace(d?.Phone) ? d!.Phone!.Trim() : parsed.Phone,
Description = raw.RawText, Description = raw.RawText,
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl, 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), Contacts = BuildContacts(d, parsed),
Tags = BuildTags(parsed, d, role, city, extraRoleTags), Tags = BuildTags(parsed, d, role, city, extraRoleTags),
}); });
@@ -382,7 +390,7 @@ public class IngestionService
SalaryMin = parsed.PayAmount, SalaryMin = parsed.PayAmount,
Description = raw.RawText, Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, Description = raw.RawText, Status = ShiftStatus.Open, Source = ShiftSource.Aggregated,
SourceUrl = raw.SourceUrl, 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 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, : parsed.PayAmount is null ? PayType.Negotiable : PayType.PerShift,
PayAmount = parsed.PayAmount, SharePercent = parsed.SharePercent, PayAmount = parsed.PayAmount, SharePercent = parsed.SharePercent,
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl, 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 Contacts = BuildContacts(d, parsed), // the ad's OWN number(s) — fresh per listing
}); });
} }
@@ -0,0 +1,52 @@
using System.Text.RegularExpressions;
namespace JobsMedical.Web.Services.Scraping;
/// <summary>
/// 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.
/// </summary>
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();
/// <summary>First of the given names that maps to a known Tehran neighborhood center (exact, then
/// substring — «میدان ونک» → «ونک»). Returns null when nothing matches.</summary>
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();
}