Jobber/shared/src/search-cities.ts
ilia 4d7c8ac0bc feat(orchestrator): job list filters — multi source/country, URL sync, exclude
- Parse location strings into country keys (shared search-cities helper).
- URL params: source, sourceExclude, countries, countriesExclude.
- Chip cycle: off → include → exclude (destructive); remote bypasses country rules.
- README: document filter behaviour and query keys.

Unrelated local changes (scorer, notes, schema, etc.) remain unstaged.

Made-with: Cursor
2026-04-06 15:50:47 -04:00

156 lines
4.6 KiB
TypeScript

import {
normalizeCountryKey,
SUPPORTED_COUNTRY_KEYS,
} from "./location-support.js";
const supportedCountryKeySet = new Set(SUPPORTED_COUNTRY_KEYS);
const LOCATION_ALIASES: Record<string, string> = {
uk: "united kingdom",
us: "united states",
usa: "united states",
};
export function normalizeLocationToken(
value: string | null | undefined,
): string {
const normalized = value?.trim().toLowerCase().replace(/\s+/g, " ") ?? "";
if (!normalized) return "";
return LOCATION_ALIASES[normalized] ?? normalized;
}
/**
* If search geography includes a supported country token (e.g. "UK", "Canada"),
* returns its normalized country key; otherwise null (e.g. "London" only).
*/
export function inferCountryKeyFromSearchGeography(
searchCities?: string | null,
jobspyLocation?: string | null,
): string | null {
const raw = searchCities?.trim() || jobspyLocation?.trim();
if (!raw) return null;
for (const token of parseSearchCitiesSetting(raw)) {
const key = normalizeCountryKey(token);
if (supportedCountryKeySet.has(key)) return key;
}
return null;
}
/**
* Parses a job listing location string and returns normalized country keys
* (e.g. "London, UK" → ["united kingdom"]). Empty when no supported country tokens.
*/
export function inferCountryKeysFromJobLocation(
location: string | null | undefined,
): string[] {
if (!location?.trim()) return [];
const keys = new Set<string>();
for (const segment of location.split(/[,;|]/)) {
const trimmed = segment.trim();
if (!trimmed) continue;
const key = normalizeCountryKey(trimmed);
if (supportedCountryKeySet.has(key)) keys.add(key);
}
const whole = normalizeCountryKey(location);
if (supportedCountryKeySet.has(whole)) keys.add(whole);
return [...keys];
}
export function parseSearchCitiesSetting(
value: string | null | undefined,
): string[] {
const trimmed = value?.trim();
if (!trimmed) return [];
const split = trimmed.includes("|")
? trimmed.split("|")
: trimmed.includes("\n")
? trimmed.split("\n")
: [trimmed];
const seen = new Set<string>();
const out: string[] = [];
for (const raw of split) {
const normalized = raw.trim();
if (!normalized) continue;
const key = normalized.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(normalized);
}
return out;
}
interface ResolveSearchCitiesOptions {
list?: string[] | null;
single?: string | null;
env?: string | null;
fallback?: string | null;
}
export function resolveSearchCities(
options: ResolveSearchCitiesOptions,
): string[] {
// Priority order:
// 1) explicit list (searchCities array in config)
// 2) explicit single value
// 3) environment fallback
// 4) final hardcoded/default fallback
if (options.list && options.list.length > 0) {
const parsedList = parseSearchCitiesSetting(options.list.join("|"));
if (parsedList.length > 0) return parsedList;
}
const fallbackCandidates = [options.single, options.env, options.fallback];
for (const candidate of fallbackCandidates) {
if (candidate === null || candidate === undefined) continue;
const parsed = parseSearchCitiesSetting(candidate);
if (parsed.length > 0) return parsed;
}
return [];
}
export function serializeSearchCitiesSetting(cities: string[]): string | null {
if (cities.length === 0) return null;
return cities.join("|");
}
export function shouldApplyStrictCityFilter(
city: string,
country: string,
): boolean {
const normalizedCity = normalizeLocationToken(city);
const normalizedCountry = normalizeCountryKey(country);
if (!normalizedCity || !normalizedCountry) return false;
return normalizedCity !== normalizedCountry;
}
export function matchesRequestedCity(
jobLocation: string | undefined,
requestedCity: string,
): boolean {
const normalizedJobLocation = normalizeLocationToken(jobLocation)
.replace(/[^a-z0-9]+/g, " ")
.trim();
const normalizedRequestedLocation = normalizeLocationToken(requestedCity)
.replace(/[^a-z0-9]+/g, " ")
.trim();
if (!normalizedJobLocation || !normalizedRequestedLocation) return false;
const jobTokens = normalizedJobLocation.split(" ");
const requestedTokens = normalizedRequestedLocation.split(" ");
if (requestedTokens.length > jobTokens.length) return false;
for (let i = 0; i <= jobTokens.length - requestedTokens.length; i += 1) {
let matches = true;
for (let j = 0; j < requestedTokens.length; j += 1) {
if (jobTokens[i + j] !== requestedTokens[j]) {
matches = false;
break;
}
}
if (matches) return true;
}
return false;
}