jd-intel 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -1,136 +1,146 @@
1
- /**
2
- * jd-intel — JD intelligence toolkit: fetch, normalize, and search job descriptions across every major ATS.
3
- *
4
- * Fetches, normalizes, and enriches job data from public ATS APIs
5
- * (Greenhouse, Lever, Ashby, SmartRecruiters, Teamtailor, Recruitee,
6
- * Workday) into a unified schema.
7
- */
8
-
9
- import { ADAPTERS, ATS_NAMES } from './adapters/index.js';
10
- import { loadRegistry, searchRegistry, detectAts, findAtsBySlug, findEntryBySlug } from './registry.js';
11
- import { applyFilters } from './filters.js';
12
-
13
- /**
14
- * Fetch jobs from a company's ATS board.
15
- *
16
- * @param {Object} options
17
- * @param {string} options.company - Company slug or name
18
- * @param {string} [options.ats] - Specific ATS platform. If omitted, auto-detects.
19
- * @param {object} [options.config] - Adapter-specific config (e.g. Workday {tenant, env, site}). Bypasses the registry; the only way to reach a Workday company not in the registry.
20
- * @param {string} [options.titleFilter] - Regex matched against title only. Use for role identity ("product manager", "staff engineer").
21
- * @param {string} [options.filter] - Regex matched across title, department, description. Use for topic/scope.
22
- * @param {number} [options.postedWithinDays] - Only return jobs posted within N days.
23
- * @param {string[]} [options.locationIncludes] - Keep jobs whose location contains any of these (case-insensitive).
24
- * @param {string[]} [options.locationExcludes] - Drop jobs whose location contains any of these (case-insensitive).
25
- * @param {number} [options.limit=100] - Maximum jobs to return after filtering.
26
- * @returns {Promise<Array>} Normalized, filtered job objects
27
- */
28
- export async function fetchJobs({
29
- company,
30
- ats,
31
- config,
32
- titleFilter,
33
- filter,
34
- postedWithinDays,
35
- locationIncludes,
36
- locationExcludes,
37
- limit = 100,
38
- } = {}) {
39
- if (!company) throw new Error('Company slug required');
40
-
41
- // Unified slug normalization: strip all non-alphanumeric (matches detectAts)
42
- const slug = company.toLowerCase().replace(/[^a-z0-9]/g, '');
43
-
44
- // Filter context is passed as an additive 2nd arg to adapters. Existing
45
- // adapters declare fetch{Name}(slug) and ignore extra positional args
46
- // (JS no-op), so this is backward-compatible. Filter-aware adapters
47
- // (e.g. Workday) use it to avoid mass detail-fetching on huge tenants.
48
- const filterContext = { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes, limit };
49
-
50
- let jobs;
51
- if (ats) {
52
- const adapter = ADAPTERS[ats];
53
- if (!adapter) throw new Error(`Unknown ATS: ${ats}. Supported: ${ATS_NAMES.join(', ')}`);
54
- // Explicit ATS: an explicitly passed config wins (the only path that
55
- // can reach a Workday company not in the registry). With no explicit
56
- // config, fall back to the registry so config-keyed adapters
57
- // (Workday) and canonically-cased registry slugs (SmartRecruiters
58
- // "Visa") also work on the explicit path, not just under auto-detect.
59
- let fetchSlug = slug;
60
- let cfg = config;
61
- let companyName;
62
- if (!cfg) {
63
- const hit = await findEntryBySlug(slug);
64
- if (hit && hit.ats === ats) {
65
- fetchSlug = hit.entry.slug;
66
- cfg = hit.entry.config;
67
- companyName = hit.entry.name;
68
- }
69
- }
70
- jobs = await adapter.fetch(fetchSlug, { config: cfg, companyName, filterContext });
71
- } else {
72
- // Consult registry first — if we know which ATS this company uses,
73
- // skip probing the others (saves API calls, clearer error semantics).
74
- // The registry entry carries the canonical slug (so the adapter is
75
- // called with the ATS's own casing, e.g. SmartRecruiters "Visa") and
76
- // any adapter-specific config (the Workday {tenant,env,site} triple).
77
- const hit = await findEntryBySlug(slug);
78
- if (hit) {
79
- jobs = await ADAPTERS[hit.ats].fetch(hit.entry.slug, {
80
- config: config || hit.entry.config,
81
- companyName: hit.entry.name,
82
- filterContext,
83
- });
84
- } else {
85
- // Discovery mode: company not in registry, probe all adapters.
86
- // (Registry-only adapters like Workday bail here via their guard.)
87
- const results = await Promise.allSettled(
88
- Object.entries(ADAPTERS).map(async ([name, adapter]) => adapter.fetch(slug, { filterContext }))
89
- );
90
- jobs = results
91
- .filter(r => r.status === 'fulfilled')
92
- .flatMap(r => r.value);
93
- }
94
- }
95
-
96
- return applyFilters(jobs, { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes, limit });
97
- }
98
-
99
- /**
100
- * Search for companies in the registry.
101
- */
102
- export async function search({ keyword, location, ats } = {}) {
103
- // For now, search is registry-based. With SQLite store, this becomes a full-text search.
104
- if (!keyword) throw new Error('Keyword required');
105
- return searchRegistry(keyword);
106
- }
107
-
108
- /**
109
- * Detect which ATS platform a company uses (probes each adapter).
110
- */
111
- export { detectAts } from './registry.js';
112
-
113
- /**
114
- * Look up which ATS a slug belongs to in the registry (cached, no network).
115
- * Returns the ATS name (e.g. "greenhouse", "workday") or null if not in registry.
116
- */
117
- export { findAtsBySlug } from './registry.js';
118
-
119
- /**
120
- * Registry management.
121
- */
122
- export const registry = {
123
- load: loadRegistry,
124
- search: searchRegistry,
125
- detect: detectAts,
126
- findAtsBySlug,
127
- findEntryBySlug,
128
- };
129
-
130
- // Re-export individual adapters for direct use
131
- export { fetchGreenhouse } from './adapters/greenhouse.js';
132
- export { fetchLever } from './adapters/lever.js';
133
- export { fetchAshby } from './adapters/ashby.js';
134
-
135
- // Re-export filter logic for reuse (e.g., by the MCP server)
136
- export { applyFilters } from './filters.js';
1
+ /**
2
+ * jd-intel — JD intelligence toolkit: fetch, normalize, and search job descriptions across every major ATS.
3
+ *
4
+ * Fetches, normalizes, and enriches job data from public ATS APIs
5
+ * (Greenhouse, Lever, Ashby, SmartRecruiters, Teamtailor, Recruitee,
6
+ * Workday) into a unified schema.
7
+ */
8
+
9
+ import { ADAPTERS, ATS_NAMES } from './adapters/index.js';
10
+ import { loadRegistry, searchRegistry, detectAts, findAtsBySlug, findEntryBySlug, getRegistrySource } from './registry.js';
11
+ import { applyFilters } from './filters.js';
12
+
13
+ /**
14
+ * Fetch jobs from a company's ATS board.
15
+ *
16
+ * @param {Object} options
17
+ * @param {string} options.company - Company slug or name
18
+ * @param {string} [options.ats] - Specific ATS platform. If omitted, auto-detects.
19
+ * @param {object} [options.config] - Adapter-specific config (e.g. Workday {tenant, env, site}). Bypasses the registry; the only way to reach a Workday company not in the registry.
20
+ * @param {string} [options.titleFilter] - Regex matched against title only. Use for role identity ("product manager", "staff engineer").
21
+ * @param {string} [options.filter] - Regex matched across title, department, description. Use for topic/scope.
22
+ * @param {number} [options.postedWithinDays] - Only return jobs posted within N days.
23
+ * @param {string[]} [options.locationIncludes] - Keep jobs whose location contains any of these (case-insensitive).
24
+ * @param {string[]} [options.locationExcludes] - Drop jobs whose location contains any of these (case-insensitive).
25
+ * @param {number} [options.limit=100] - Maximum jobs to return after filtering.
26
+ * @returns {Promise<Array>} Normalized, filtered job objects
27
+ */
28
+ export async function fetchJobs({
29
+ company,
30
+ ats,
31
+ config,
32
+ titleFilter,
33
+ filter,
34
+ postedWithinDays,
35
+ locationIncludes,
36
+ locationExcludes,
37
+ limit = 100,
38
+ } = {}) {
39
+ if (!company) throw new Error('Company slug required');
40
+
41
+ // Unified slug normalization: strip all non-alphanumeric (matches detectAts)
42
+ const slug = company.toLowerCase().replace(/[^a-z0-9]/g, '');
43
+
44
+ // Filter context is passed as an additive 2nd arg to adapters. Existing
45
+ // adapters declare fetch{Name}(slug) and ignore extra positional args
46
+ // (JS no-op), so this is backward-compatible. Filter-aware adapters
47
+ // (e.g. Workday) use it to avoid mass detail-fetching on huge tenants.
48
+ const filterContext = { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes, limit };
49
+
50
+ let jobs;
51
+ if (ats) {
52
+ const adapter = ADAPTERS[ats];
53
+ if (!adapter) throw new Error(`Unknown ATS: ${ats}. Supported: ${ATS_NAMES.join(', ')}`);
54
+ // Explicit ATS: an explicitly passed config wins (the only path that
55
+ // can reach a Workday company not in the registry). With no explicit
56
+ // config, fall back to the registry so config-keyed adapters
57
+ // (Workday) and canonically-cased registry slugs (SmartRecruiters
58
+ // "Visa") also work on the explicit path, not just under auto-detect.
59
+ let fetchSlug = slug;
60
+ let cfg = config;
61
+ let companyName;
62
+ if (!cfg) {
63
+ const hit = await findEntryBySlug(slug);
64
+ if (hit && hit.ats === ats) {
65
+ fetchSlug = hit.entry.slug;
66
+ cfg = hit.entry.config;
67
+ companyName = hit.entry.name;
68
+ }
69
+ }
70
+ jobs = await adapter.fetch(fetchSlug, { config: cfg, companyName, filterContext });
71
+ } else {
72
+ // Consult registry first — if we know which ATS this company uses,
73
+ // skip probing the others (saves API calls, clearer error semantics).
74
+ // The registry entry carries the canonical slug (so the adapter is
75
+ // called with the ATS's own casing, e.g. SmartRecruiters "Visa") and
76
+ // any adapter-specific config (the Workday {tenant,env,site} triple).
77
+ const hit = await findEntryBySlug(slug);
78
+ if (hit) {
79
+ jobs = await ADAPTERS[hit.ats].fetch(hit.entry.slug, {
80
+ config: config || hit.entry.config,
81
+ companyName: hit.entry.name,
82
+ filterContext,
83
+ });
84
+ } else {
85
+ // Discovery mode: company not in registry, probe all adapters.
86
+ // (Registry-only adapters like Workday bail here via their guard.)
87
+ const results = await Promise.allSettled(
88
+ Object.entries(ADAPTERS).map(async ([name, adapter]) => adapter.fetch(slug, { filterContext }))
89
+ );
90
+ jobs = results
91
+ .filter(r => r.status === 'fulfilled')
92
+ .flatMap(r => r.value);
93
+ }
94
+ }
95
+
96
+ return applyFilters(jobs, { titleFilter, filter, postedWithinDays, locationIncludes, locationExcludes, limit });
97
+ }
98
+
99
+ /**
100
+ * Search for companies in the registry.
101
+ */
102
+ export async function search({ keyword, location, ats } = {}) {
103
+ // For now, search is registry-based. With SQLite store, this becomes a full-text search.
104
+ if (!keyword) throw new Error('Keyword required');
105
+ return searchRegistry(keyword);
106
+ }
107
+
108
+ /**
109
+ * Detect which ATS platform a company uses (probes each adapter).
110
+ */
111
+ export { detectAts } from './registry.js';
112
+
113
+ /**
114
+ * Look up which ATS a slug belongs to in the registry (cached, no network).
115
+ * Returns the ATS name (e.g. "greenhouse", "workday") or null if not in registry.
116
+ */
117
+ export { findAtsBySlug } from './registry.js';
118
+
119
+ /**
120
+ * Registry management.
121
+ */
122
+ export const registry = {
123
+ load: loadRegistry,
124
+ search: searchRegistry,
125
+ detect: detectAts,
126
+ findAtsBySlug,
127
+ findEntryBySlug,
128
+ getSource: getRegistrySource,
129
+ };
130
+
131
+ // Re-export individual adapters for direct use
132
+ export { fetchGreenhouse } from './adapters/greenhouse.js';
133
+ export { fetchLever } from './adapters/lever.js';
134
+ export { fetchAshby } from './adapters/ashby.js';
135
+
136
+ // Re-export filter logic for reuse (e.g., by the MCP server)
137
+ export { applyFilters } from './filters.js';
138
+
139
+ // Re-export the list of supported ATS names (e.g. so the MCP layer can report
140
+ // the full set detectAts probes, instead of hardcoding a stale subset).
141
+ export { ATS_NAMES };
142
+
143
+ // Error taxonomy + typed error. Adapters throw AtsError with a stable .code
144
+ // (ats_unreachable / rate_limited) so the MCP layer maps failures without
145
+ // parsing messages. ERROR_CODES is the single source of truth for both.
146
+ export { ERROR_CODES, AtsError } from './errors.js';
package/src/registry.js CHANGED
@@ -1,114 +1,164 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { join, dirname } from 'node:path';
3
- import { fileURLToPath } from 'node:url';
4
-
5
- const __dirname = dirname(fileURLToPath(import.meta.url));
6
- const REGISTRY_DIR = join(__dirname, '..', 'registry');
7
-
8
- let cache = {};
9
-
10
- /**
11
- * Load company registry for a specific ATS or all ATS platforms.
12
- */
13
- export async function loadRegistry(ats) {
14
- if (ats && cache[ats]) return cache[ats];
15
-
16
- if (ats) {
17
- try {
18
- const data = await readFile(join(REGISTRY_DIR, `${ats}.json`), 'utf-8');
19
- cache[ats] = JSON.parse(data);
20
- return cache[ats];
21
- } catch {
22
- return [];
23
- }
24
- }
25
-
26
- // Load all
27
- const all = {};
28
- for (const platform of ['greenhouse', 'lever', 'ashby', 'smartrecruiters', 'teamtailor', 'recruitee', 'workday']) {
29
- try {
30
- const data = await readFile(join(REGISTRY_DIR, `${platform}.json`), 'utf-8');
31
- all[platform] = JSON.parse(data);
32
- cache[platform] = all[platform];
33
- } catch {
34
- all[platform] = [];
35
- }
36
- }
37
- return all;
38
- }
39
-
40
- /**
41
- * Search registry for companies matching a query.
42
- */
43
- export async function searchRegistry(query) {
44
- const all = await loadRegistry();
45
- const lower = query.toLowerCase();
46
- const results = [];
47
-
48
- for (const [ats, companies] of Object.entries(all)) {
49
- for (const company of companies) {
50
- const name = (company.name || company.slug || '').toLowerCase();
51
- const sector = (company.sector || '').toLowerCase();
52
- if (name.includes(lower) || sector.includes(lower)) {
53
- results.push({ ...company, ats });
54
- }
55
- }
56
- }
57
-
58
- return results;
59
- }
60
-
61
- // Slug match is case/punctuation-insensitive: registry slugs are stored
62
- // in each ATS's canonical form (SmartRecruiters uses PascalCase, e.g.
63
- // "Visa"), but callers pass a lowercased/alnum-stripped slug. Comparing
64
- // normalized forms keeps registry-first routing working for those.
65
- const normSlug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
66
-
67
- /**
68
- * Look up which ATS a slug belongs to in the registry.
69
- * Returns the ATS name (e.g., "greenhouse") or null if not in registry.
70
- */
71
- export async function findAtsBySlug(slug) {
72
- const all = await loadRegistry();
73
- const key = normSlug(slug);
74
- for (const [ats, companies] of Object.entries(all)) {
75
- if (companies.some(c => normSlug(c.slug) === key)) return ats;
76
- }
77
- return null;
78
- }
79
-
80
- /**
81
- * Look up the full registry entry for a slug, with its ATS.
82
- * Unlike findAtsBySlug (returns just the ats name), this returns the
83
- * whole entry so callers can read adapter-specific config (e.g. the
84
- * Workday {tenant, env, site} triple). Additive — does not change
85
- * findAtsBySlug, which has other callers.
86
- *
87
- * @returns {Promise<{ats: string, entry: object}|null>}
88
- */
89
- export async function findEntryBySlug(slug) {
90
- const all = await loadRegistry();
91
- const key = normSlug(slug);
92
- for (const [ats, companies] of Object.entries(all)) {
93
- const entry = companies.find(c => normSlug(c.slug) === key);
94
- if (entry) return { ats, entry };
95
- }
96
- return null;
97
- }
98
-
99
- /**
100
- * Auto-detect which ATS a company uses.
101
- */
102
- export async function detectAts(companyName) {
103
- const { ADAPTERS } = await import('./adapters/index.js');
104
- const slug = companyName.toLowerCase().replace(/[^a-z0-9]/g, '');
105
-
106
- const results = [];
107
- const checks = Object.entries(ADAPTERS).map(async ([ats, adapter]) => {
108
- const found = await adapter.has(slug);
109
- if (found) results.push({ ats, slug });
110
- });
111
-
112
- await Promise.allSettled(checks);
113
- return results;
114
- }
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const REGISTRY_DIR = join(__dirname, '..', 'registry');
7
+
8
+ const PLATFORMS = ['greenhouse', 'lever', 'ashby', 'smartrecruiters', 'teamtailor', 'recruitee', 'workday'];
9
+
10
+ // Network-first registry. A hosted copy lets installed bundles AND npx users
11
+ // pick up newly-added companies without reinstalling; the on-disk copy that
12
+ // ships with the package is the guaranteed offline fallback. The base URL is
13
+ // resolved at call time so it stays overridable: point JD_INTEL_REGISTRY_URL
14
+ // at a different host, or set it to '' to force disk-only (tests, air-gapped).
15
+ const DEFAULT_REGISTRY_URL = 'https://prpmdev.github.io/jd-intel/registry';
16
+ const FETCH_TIMEOUT_MS = 2500;
17
+
18
+ function registryBaseUrl() {
19
+ return process.env.JD_INTEL_REGISTRY_URL !== undefined
20
+ ? process.env.JD_INTEL_REGISTRY_URL
21
+ : DEFAULT_REGISTRY_URL;
22
+ }
23
+
24
+ let cache = {};
25
+ let sources = {}; // platform -> 'network' | 'disk-fallback'
26
+
27
+ async function fetchPlatform(platform) {
28
+ const base = registryBaseUrl();
29
+ if (!base) throw new Error('registry network disabled');
30
+ const res = await fetch(`${base}/${platform}.json`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
31
+ if (!res.ok) throw new Error(`registry fetch ${platform}: HTTP ${res.status}`);
32
+ const data = await res.json();
33
+ if (!Array.isArray(data)) throw new Error(`registry fetch ${platform}: not an array`);
34
+ return data;
35
+ }
36
+
37
+ async function readPlatform(platform) {
38
+ const data = await readFile(join(REGISTRY_DIR, `${platform}.json`), 'utf-8');
39
+ return JSON.parse(data);
40
+ }
41
+
42
+ // Load one platform: hosted copy first, on-disk fallback on ANY failure
43
+ // (offline, non-200, timeout, malformed). Cached per process after first load.
44
+ async function loadPlatform(platform) {
45
+ if (cache[platform]) return cache[platform];
46
+ try {
47
+ cache[platform] = await fetchPlatform(platform);
48
+ sources[platform] = 'network';
49
+ } catch {
50
+ try {
51
+ cache[platform] = await readPlatform(platform);
52
+ } catch {
53
+ cache[platform] = [];
54
+ }
55
+ sources[platform] = 'disk-fallback';
56
+ }
57
+ return cache[platform];
58
+ }
59
+
60
+ /**
61
+ * Load company registry for a specific ATS or all ATS platforms.
62
+ * Network-first with on-disk fallback (see registryBaseUrl).
63
+ */
64
+ export async function loadRegistry(ats) {
65
+ if (ats) return loadPlatform(ats);
66
+ const all = {};
67
+ await Promise.all(PLATFORMS.map(async (platform) => {
68
+ all[platform] = await loadPlatform(platform);
69
+ }));
70
+ return all;
71
+ }
72
+
73
+ /**
74
+ * Where the registry data loaded this process came from:
75
+ * 'network' every loaded platform came from the hosted copy
76
+ * 'disk-fallback' every loaded platform fell back to the bundled copy
77
+ * 'mixed' some of each
78
+ * 'unknown' nothing loaded yet
79
+ * Surfaced in MCP response metadata so the AI can tell the user whether the
80
+ * company list is live or the bundled snapshot.
81
+ */
82
+ export function getRegistrySource() {
83
+ const vals = Object.values(sources);
84
+ if (vals.length === 0) return 'unknown';
85
+ if (vals.every((v) => v === 'network')) return 'network';
86
+ if (vals.every((v) => v === 'disk-fallback')) return 'disk-fallback';
87
+ return 'mixed';
88
+ }
89
+
90
+ /**
91
+ * Search registry for companies matching a query.
92
+ */
93
+ export async function searchRegistry(query) {
94
+ const all = await loadRegistry();
95
+ const lower = query.toLowerCase();
96
+ const results = [];
97
+
98
+ for (const [ats, companies] of Object.entries(all)) {
99
+ for (const company of companies) {
100
+ const name = (company.name || company.slug || '').toLowerCase();
101
+ const sector = (company.sector || '').toLowerCase();
102
+ if (name.includes(lower) || sector.includes(lower)) {
103
+ results.push({ ...company, ats });
104
+ }
105
+ }
106
+ }
107
+
108
+ return results;
109
+ }
110
+
111
+ // Slug match is case/punctuation-insensitive: registry slugs are stored
112
+ // in each ATS's canonical form (SmartRecruiters uses PascalCase, e.g.
113
+ // "Visa"), but callers pass a lowercased/alnum-stripped slug. Comparing
114
+ // normalized forms keeps registry-first routing working for those.
115
+ const normSlug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
116
+
117
+ /**
118
+ * Look up which ATS a slug belongs to in the registry.
119
+ * Returns the ATS name (e.g., "greenhouse") or null if not in registry.
120
+ */
121
+ export async function findAtsBySlug(slug) {
122
+ const all = await loadRegistry();
123
+ const key = normSlug(slug);
124
+ for (const [ats, companies] of Object.entries(all)) {
125
+ if (companies.some(c => normSlug(c.slug) === key)) return ats;
126
+ }
127
+ return null;
128
+ }
129
+
130
+ /**
131
+ * Look up the full registry entry for a slug, with its ATS.
132
+ * Unlike findAtsBySlug (returns just the ats name), this returns the
133
+ * whole entry so callers can read adapter-specific config (e.g. the
134
+ * Workday {tenant, env, site} triple). Additive — does not change
135
+ * findAtsBySlug, which has other callers.
136
+ *
137
+ * @returns {Promise<{ats: string, entry: object}|null>}
138
+ */
139
+ export async function findEntryBySlug(slug) {
140
+ const all = await loadRegistry();
141
+ const key = normSlug(slug);
142
+ for (const [ats, companies] of Object.entries(all)) {
143
+ const entry = companies.find(c => normSlug(c.slug) === key);
144
+ if (entry) return { ats, entry };
145
+ }
146
+ return null;
147
+ }
148
+
149
+ /**
150
+ * Auto-detect which ATS a company uses.
151
+ */
152
+ export async function detectAts(companyName) {
153
+ const { ADAPTERS } = await import('./adapters/index.js');
154
+ const slug = companyName.toLowerCase().replace(/[^a-z0-9]/g, '');
155
+
156
+ const results = [];
157
+ const checks = Object.entries(ADAPTERS).map(async ([ats, adapter]) => {
158
+ const found = await adapter.has(slug);
159
+ if (found) results.push({ ats, slug });
160
+ });
161
+
162
+ await Promise.allSettled(checks);
163
+ return results;
164
+ }