- 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
156 lines
4.6 KiB
TypeScript
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;
|
|
}
|