openings 0.1.31 → 0.1.33

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openings",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "description": "Find evidence-grounded jobs, including relevant roles you may not have searched for, without accounts or API keys.",
5
5
  "author": { "name": "Openings contributors" },
6
6
  "license": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openings",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "description": "A free, candidate-safe job-search substrate for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": { "type": "git", "url": "git+https://github.com/abhay-avagama/hiring-agent.git" },
package/src/catalog.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CASCADE_MINIMUM, CASCADE_WINDOWS, type Company, type Job, type JobAge, type JobSummary, type SearchQuery, type SearchWindow } from "./types.ts";
2
- import { providerSpec, type JsonGet } from "./providers.ts";
2
+ import { decodeEntities, providerSpec, type JsonGet } from "./providers.ts";
3
3
  import { crawlSite, sitePostingsToJobs } from "./jobposting-site.ts";
4
4
  import { classifyJob, isEligibleForCountry, normalizeLocation } from "./locations.ts";
5
5
 
@@ -99,12 +99,7 @@ export function createCatalog(options: CatalogOptions): Catalog {
99
99
  const company = options.companies.find((candidate) => candidate.ats === ats && candidate.slug === slug);
100
100
  if (!company) return null;
101
101
  let job = (await fetchJobs(company)).find((candidate) => candidate.id === id) ?? null;
102
- if (job && company.ats === "workday" && !job.description.trim()) {
103
- const description = await fetchWorkdayDescription(company, job.url, fetcher);
104
- job = { ...job, description };
105
- }
106
- const spec = job && !job.description.trim() ? providerSpec(company.ats) : undefined;
107
- if (job && spec?.detail) job = { ...job, description: await spec.detail(company, job, jsonGetter(fetcher, company.name)) };
102
+ if (job && !job.description.trim()) job = { ...job, description: await fetchJobDescription(company, job, fetcher) };
108
103
  if (job && !job.description.trim()) throw new Error(`Full description unavailable for job: ${id}`);
109
104
  return job;
110
105
  },
@@ -402,6 +397,13 @@ export function workdayPostedAt(label: string | undefined, now: number = Date.no
402
397
  return new Date(now - days * 86_400_000).toISOString().slice(0, 10);
403
398
  }
404
399
 
400
+ /** The full description for a job whose listing carried none (Workday and providers with a detail endpoint); "" when there is no detail source. */
401
+ export async function fetchJobDescription(company: Company, job: Job, fetcher: Fetch): Promise<string> {
402
+ if (company.ats === "workday") return fetchWorkdayDescription(company, job.url, fetcher);
403
+ const spec = providerSpec(company.ats);
404
+ return spec?.detail ? spec.detail(company, job, jsonGetter(fetcher, company.name)) : "";
405
+ }
406
+
405
407
  async function fetchWorkdayDescription(company: Company, jobUrl: string, fetcher: Fetch): Promise<string> {
406
408
  const source = parseWorkdayToken(company.token);
407
409
  const path = new URL(jobUrl).pathname.replace(new RegExp(`^/en-US/${escapeRegExp(source.site)}`), "");
@@ -516,10 +518,7 @@ function stripHtml(value: string): string {
516
518
  .replace(/<br\s*\/?\s*>/gi, "\n")
517
519
  .replace(/<\/p>/gi, "\n\n")
518
520
  .replace(/<[^>]+>/g, "")
519
- .replace(/&nbsp;/g, " ")
520
- .replace(/&amp;/g, "&")
521
- .replace(/&lt;/g, "<")
522
- .replace(/&gt;/g, ">")
521
+ .replace(/&[#a-z0-9]+;/gi, (entity) => decodeEntities(entity))
523
522
  .replace(/\n{3,}/g, "\n\n")
524
523
  .trim();
525
524
  }
@@ -36,7 +36,8 @@ export function createCrawlReporter(options: { url: string; fetcher?: Fetch; tim
36
36
  method: "POST",
37
37
  headers: { "content-type": "application/json", "content-encoding": "gzip" },
38
38
  body: Bun.gzipSync(JSON.stringify(payload)),
39
- signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
39
+ // Big employers send tens of megabytes and the aggregator ingests one report at a time; give them room.
40
+ signal: AbortSignal.timeout(options.timeoutMs ?? 120_000),
40
41
  });
41
42
  if (!response.ok) throw new Error(`Aggregator rejected crawl report: HTTP ${response.status}`);
42
43
  };
package/src/crawler.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { FetchJobsObserver } from "./catalog.ts";
2
+ import { statedExperience } from "./experience.ts";
2
3
  import { partitionFor, type Company, type CrawlFailure, type CrawlReport, type CrawlSourceResult, type Job, type JobPartition, type JobSnapshot } from "./types.ts";
3
4
 
4
5
  export interface SnapshotStore {
@@ -17,6 +18,10 @@ interface CrawlerOptions {
17
18
  sourceLimit?: number;
18
19
  workdayPageDelayMs?: number;
19
20
  workdayCountries?: string[];
21
+ /** Fetch a job's full description when its listing had none; used to read required experience. */
22
+ describe?(source: Company, job: Job): Promise<string>;
23
+ /** Only roles open to these countries, posted in the last 30 days, get a description fetch. Empty: none do. */
24
+ describeCountries?: string[];
20
25
  pacingNow?: () => number;
21
26
  pacingSleep?: (delayMs: number) => Promise<void>;
22
27
  now?: () => Date;
@@ -40,6 +45,30 @@ export function createCrawler(options: CrawlerOptions): Crawler {
40
45
  const pacingSleep = options.pacingSleep ?? ((delayMs: number) => new Promise<void>((resolve) => setTimeout(resolve, delayMs)));
41
46
  let previousStart: number | undefined;
42
47
  let pacingGate = Promise.resolve();
48
+ const describeCountries = new Set(options.describeCountries ?? []);
49
+
50
+ /** Required experience for every job: from its description, the last crawl's reading, or a paced detail fetch for recent roles in describeCountries. */
51
+ async function withExperience(source: Company, jobs: Job[], previous: Job[] | undefined): Promise<Job[]> {
52
+ const known = new Map((previous ?? []).filter((job) => job.experience !== undefined).map((job) => [job.id, job.experience]));
53
+ const since = now().getTime() - 30 * 86_400_000;
54
+ const deadline = Date.now() + DESCRIBE_BUDGET_MS;
55
+ let budget = DESCRIBE_LIMIT;
56
+ const out: Job[] = [];
57
+ for (const job of jobs) {
58
+ if (job.experience !== undefined) out.push(job);
59
+ else if (job.description.trim()) out.push({ ...job, experience: statedExperience(job.description) });
60
+ else if (known.has(job.id)) out.push({ ...job, experience: known.get(job.id) });
61
+ else if (options.describe && budget > 0 && Date.now() < deadline && job.eligibleCountries.some((code) => describeCountries.has(code)) && Date.parse(job.updatedAt ?? "") >= since) {
62
+ budget -= 1;
63
+ try {
64
+ if (options.workdayPageDelayMs) await pacingSleep(options.workdayPageDelayMs);
65
+ const description = await options.describe(source, job);
66
+ out.push(description.trim() ? { ...job, experience: statedExperience(description) } : job);
67
+ } catch { out.push(job); } // unread; the next crawl tries again
68
+ } else out.push(job);
69
+ }
70
+ return out;
71
+ }
43
72
 
44
73
  async function paceSourceStart() {
45
74
  if (!sourceStartDelayMs) return;
@@ -92,11 +121,12 @@ export function createCrawler(options: CrawlerOptions): Crawler {
92
121
  const controller = new AbortController();
93
122
  const timer = setTimeout(() => controller.abort(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
94
123
  try {
95
- const jobs = await options.fetchJobs(source, controller.signal, {
124
+ const listed = await options.fetchJobs(source, controller.signal, {
96
125
  onBackoff: ({ status, delayMs }) => { metric.backoffMs += delayMs; if (status === 429) metric.throttles += 1; },
97
126
  workdayPageDelayMs: options.workdayPageDelayMs,
98
127
  workdayCountries: options.workdayCountries,
99
128
  });
129
+ const jobs = await withExperience(source, listed, partitionFor(partitions, source.slug)?.jobs);
100
130
  partitions[source.slug] = { fetchedAt: now().toISOString(), jobs };
101
131
  succeeded += 1;
102
132
  metric.status = "succeeded";
@@ -129,13 +159,20 @@ export function createCrawler(options: CrawlerOptions): Crawler {
129
159
  await options.store.write({ version: 1, updatedAt: finishedAt, partitions, lastCrawl: report });
130
160
  if (options.onCrawled) {
131
161
  const crawled = selectedSources.filter((source) => metrics.get(source.slug)!.status === "succeeded");
132
- await Promise.all(crawled.map((source) => Promise.resolve().then(() => options.onCrawled!(source, partitions[source.slug]!)).catch(() => undefined)));
162
+ // Four at a time: sending a thousand reports at once made the largest ones time out while the aggregator queued them.
163
+ let next = 0;
164
+ const send = async () => { while (next < crawled.length) { const source = crawled[next++]!; await Promise.resolve().then(() => options.onCrawled!(source, partitions[source.slug]!)).catch(() => undefined); } };
165
+ await Promise.all(Array.from({ length: Math.min(4, crawled.length) }, send));
133
166
  }
134
167
  return report;
135
168
  },
136
169
  };
137
170
  }
138
171
 
172
+ // ponytail: per-source caps keep one crawl bounded; a big backlog (first run) drains over a few nights.
173
+ const DESCRIBE_LIMIT = 150;
174
+ const DESCRIBE_BUDGET_MS = 180_000;
175
+
139
176
  function partitionTime(value: string | undefined): number {
140
177
  const timestamp = Date.parse(value ?? "");
141
178
  return Number.isFinite(timestamp) ? timestamp : Number.NEGATIVE_INFINITY;
@@ -0,0 +1,50 @@
1
+ import { decodeEntities } from "./providers.ts";
2
+
3
+ /** Years of experience a posting asks for, read from its own text. */
4
+ export interface Experience { min: number; max?: number }
5
+
6
+ const WORDS: Record<string, number> = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, twelve: 12, fifteen: 15 };
7
+ const NUM = String.raw`(\d{1,2}(?:\.\d)?|${Object.keys(WORDS).join("|")})`;
8
+ const RANGE = String.raw`(?<![\d.])${NUM}\s*\+?\s*(?:(?:-|–|—|to)\s*${NUM}\s*)?\+?\s*(?:years?|yrs?)\+?(?:['’]s?)?`;
9
+ // "5+ years of hands-on Java experience" or "Experience: 2-5 years" / "Years of experience 6 to 8 years".
10
+ const YEARS_THEN_EXPERIENCE = new RegExp(String.raw`${RANGE}(?:\s+of)?(?:\s+[\w/&,'’()-]+){0,10}?\s+(?:experiences?|exp)\b`, "gi");
11
+ const EXPERIENCE_THEN_YEARS = new RegExp(String.raw`\b(?:experiences?|exp)\b[^.\n\d]{0,30}?${RANGE}`, "gi");
12
+ // "minimum 15+ years" with no word "experience" after it.
13
+ const MINIMUM_YEARS = new RegExp(String.raw`\b(?:minimum|min\.?|at least|atleast)\s*(?:of\s*)?${RANGE}`, "gi");
14
+ // Company history reads the same way ("our 135 years of experience"); skip matches that talk about the employer.
15
+ const ABOUT_EMPLOYER = /\b(?:our (?:company|firm|group|organi[sz]ation|brands?|history|legacy)|(?:company|firm|group) (?:has|with)|founded|established|history|legacy|heritage)\b[^.\n?!:;•·-]*$/i;
16
+ const MIN_PREFIX = /\b(?:minimum|min\.?|at least|atleast)\s*(?:of\s*)?$/i;
17
+
18
+ function value(text: string | undefined): number | undefined {
19
+ if (!text) return undefined;
20
+ return WORDS[text.toLowerCase()] ?? Number(text);
21
+ }
22
+
23
+ /** The first experience requirement the text states, or null when it states none. */
24
+ export function statedExperience(description: string): Experience | null {
25
+ const text = decodeEntities(description);
26
+ const found: Array<{ index: number; experience: Experience }> = [];
27
+ for (const pattern of [YEARS_THEN_EXPERIENCE, EXPERIENCE_THEN_YEARS, MINIMUM_YEARS]) {
28
+ for (const match of text.matchAll(pattern)) {
29
+ const [low, high] = [value(match[1]), value(match[2])];
30
+ if (low === undefined || !Number.isFinite(low)) continue;
31
+ // Requirements rarely pass 20 years; bigger numbers are nearly always an employer's history.
32
+ if (low > 20 || (high !== undefined && (high < low || high > 40))) continue;
33
+ const before = text.slice(Math.max(0, match.index - 60), match.index);
34
+ if (pattern !== MINIMUM_YEARS && ABOUT_EMPLOYER.test(before) && !MIN_PREFIX.test(before)) continue;
35
+ found.push({ index: match.index, experience: high !== undefined && high !== low ? { min: low, max: high } : { min: low } });
36
+ }
37
+ }
38
+ found.sort((left, right) => left.index - right.index);
39
+ return found[0]?.experience ?? null;
40
+ }
41
+
42
+ /** Entry-level titles (intern, trainee, fresher, graduate) as a rough 0–1 year estimate when the posting states nothing. */
43
+ export function titleExperience(title: string): Experience | undefined {
44
+ return /\b(?:intern|internship|trainee|freshers?|graduate|apprentice(?:ship)?|entry[- ]level)\b/i.test(title) ? { min: 0, max: 1 } : undefined;
45
+ }
46
+
47
+ /** "2–5 yrs", "5+ yrs". */
48
+ export function experienceLabel(experience: Experience): string {
49
+ return experience.max === undefined ? `${experience.min}+ yrs` : `${experience.min}–${experience.max} yrs`;
50
+ }
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ export const companies: Company[] = Object.entries(companyData).map(([slug, valu
17
17
 
18
18
  export const catalog = createCatalog({ companies });
19
19
  export { createCatalog } from "./catalog.ts";
20
+ export { experienceLabel, statedExperience, titleExperience, type Experience } from "./experience.ts";
20
21
  export type { Catalog } from "./catalog.ts";
21
22
  export type { Ats, Company, CrawlReport, Job, JobPartition, JobSnapshot, JobSummary, SearchQuery } from "./types.ts";
22
23
  export { createCrawlReporter, fetchSeedSnapshot, resolveAggregatorUrl } from "./crawl-reporting.ts";
@@ -1,3 +1,4 @@
1
+ import { experienceLabel } from "./experience.ts";
1
2
  import { validateCandidateProfileEvidence, type CandidateProfile } from "./candidate-profile.ts";
2
3
  import { isEligibleForCountry, normalizeLocation } from "./locations.ts";
3
4
  import { detectRequirementTerms, findTransferability, matchesExactSkillEvidence, requiresExactSkillEvidence, type TransferabilityKind } from "./requirement-vocabulary.ts";
@@ -258,6 +259,13 @@ function supportedRequirements(profile: CandidateProfile, requirements: string[]
258
259
  const seniorityTerms = ["manager", "principal", "staff", "lead", "senior", "mid", "junior", "intern"] as const;
259
260
  function seniorityAlignment(profile: CandidateProfile, intent: CandidateIntent, job: Job): { score: number; reason?: string } {
260
261
  const requested = intent.seniority?.map((value) => value.toLocaleLowerCase()) ?? profile.inferences.filter((inference) => inference.kind === "seniority").map((inference) => inference.value);
262
+ const resumeYears = profile.inferences.find((inference) => inference.kind === "approximate_experience_years")?.value;
263
+ if (job.experience && typeof resumeYears === "number" && !intent.seniority?.length) {
264
+ // The posting's own range is better evidence than a seniority word in the title. A year short or a few over still fits.
265
+ const asked = experienceLabel(job.experience);
266
+ const fits = resumeYears >= job.experience.min - 1 && (job.experience.max === undefined || resumeYears <= job.experience.max + 3);
267
+ return { score: fits ? 3 : -3, reason: `posting asks for ${asked}; resume shows about ${resumeYears} years` };
268
+ }
261
269
  const jobSeniority = seniorityTerms.find((term) => includesPhrase(job.title, term));
262
270
  if (!jobSeniority) return { score: 0 };
263
271
  if (!requested.length) {
@@ -1,5 +1,5 @@
1
1
  import { classifyJob } from "./locations.ts";
2
- import { countryLabel } from "./providers.ts";
2
+ import { countryLabel, decodeEntities } from "./providers.ts";
3
3
  import { extractLinks, fetchSafePage, robotsAllows, type PageTransport, type ResolveHost } from "./safe-head.ts";
4
4
  import type { Job } from "./types.ts";
5
5
 
@@ -158,5 +158,5 @@ export function sitePostingsToJobs(company: { slug: string; name: string }, post
158
158
  function hash(value: string): string { let h = 2166136261; for (const ch of value) { h ^= ch.charCodeAt(0); h = Math.imul(h, 16777619) >>> 0; } return h.toString(16); }
159
159
  function text(value: unknown): string { return typeof value === "string" ? value.trim() : typeof value === "number" ? String(value) : ""; }
160
160
  function isoDate(value: unknown): string | undefined { const time = Date.parse(text(value)); return Number.isFinite(time) ? new Date(time).toISOString() : undefined; }
161
- function stripHtml(value: string): string { return value.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/\s+/g, " ").trim(); }
161
+ function stripHtml(value: string): string { return decodeEntities(value.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim(); }
162
162
  function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
package/src/local-jobs.ts CHANGED
@@ -15,6 +15,8 @@ interface LocalJobsOptions {
15
15
  sourceLimit?: number;
16
16
  workdayPageDelayMs?: number;
17
17
  workdayCountries?: string[];
18
+ describe?(source: Company, job: Job): Promise<string>;
19
+ describeCountries?: string[];
18
20
  now?: () => Date;
19
21
  onCrawled?(source: Company, partition: JobPartition): void | Promise<void>;
20
22
  }
package/src/mcp.ts CHANGED
@@ -21,6 +21,7 @@ interface ToolHandler {
21
21
  export const FLOW_INSTRUCTIONS = [
22
22
  "Openings flow: 1) call prepare_job_search for the person's countries; it is normally ready in one call. 2) Ask for the resume before exploring roles, because recommend_jobs ranks by evidence from it. 3) If the person declines a resume, use search_jobs with their keywords. 4) If they decline that too, use your judgement.",
23
23
  "Freshness: results start with roles posted in the last 7 days and widen to 14, 30, then everything only when fewer than 5 appear; each result carries age (new, older, stale, undated) and postedDaysAgo. Say which window results came from and present older or stale roles as possibly still open, never as current.",
24
+ "Experience: a job's experience field is the years its posting states ({ min, max }); null means the posting states none, and absent means it was not read. Quote it as the employer's requirement.",
24
25
  "Every match carries applyUrl, the employer's own posting; include it. When recommend_jobs reports no_matches, relay its explanation and nextMoves instead of searching silently.",
25
26
  ].join("\n");
26
27
 
package/src/providers.ts CHANGED
@@ -294,10 +294,20 @@ function validToken(value: string | undefined): string | null {
294
294
  return value && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) && !RESERVED_TOKENS.has(value.toLowerCase()) ? value : null;
295
295
  }
296
296
 
297
+ const NAMED_ENTITIES: Record<string, string> = { nbsp: " ", amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", ndash: "–", mdash: "—", lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”", bull: "•", hellip: "…" };
298
+ /** Decode HTML entities in one pass (Workday sends "4&#43; years"), so "&amp;#43;" stays "&#43;" instead of becoming "+". */
299
+ export function decodeEntities(value: string): string {
300
+ return value.replace(/&(#\d{1,7}|#x[0-9a-f]{1,6}|[a-z]{2,8});/gi, (entity, code: string) => {
301
+ if (code[0] !== "#") return NAMED_ENTITIES[code.toLowerCase()] ?? entity;
302
+ const point = code[1] === "x" || code[1] === "X" ? parseInt(code.slice(2), 16) : Number(code.slice(1));
303
+ return point > 0 && point <= 0x10ffff ? String.fromCodePoint(point) : entity;
304
+ });
305
+ }
306
+
297
307
  export function plainText(value: string): string {
298
308
  return value
299
309
  .replace(/<br\s*\/?\s*>/gi, "\n").replace(/<\/(p|li|div|h[1-6])>/gi, "\n").replace(/<[^>]+>/g, "")
300
- .replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'")
310
+ .replace(/&[#a-z0-9]+;/gi, (entity) => decodeEntities(entity))
301
311
  .replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
302
312
  }
303
313
 
package/src/runtime.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { join } from "node:path";
2
2
  import type { SnapshotStore } from "./crawler.ts";
3
3
  import type { Company, SearchQuery } from "./types.ts";
4
- import { fetchSourceJobs } from "./catalog.ts";
4
+ import { fetchJobDescription, fetchSourceJobs } from "./catalog.ts";
5
5
  import { createCrawlReporter, fetchSeedSnapshot, resolveAggregatorUrl } from "./crawl-reporting.ts";
6
6
  import { createUsageReporter, type UsageReporter } from "./usage.ts";
7
7
  import { VERSION } from "./version.ts";
@@ -33,6 +33,9 @@ export function createRuntime(options: { dataDir?: string; concurrency?: number;
33
33
  sourceLimit: options.sourceLimit,
34
34
  workdayPageDelayMs: options.workdayPageDelayMs,
35
35
  workdayCountries,
36
+ // Server crawls set OPENINGS_DESCRIBE_COUNTRIES (IN) to read required experience from Workday detail pages; installs skip the extra requests.
37
+ describe: (source, job) => fetchJobDescription(source, job, globalThis.fetch),
38
+ describeCountries: (process.env.OPENINGS_DESCRIBE_COUNTRIES ?? "").split(",").map((code) => code.trim().toUpperCase()).filter((code) => /^[A-Z]{2}$/.test(code)),
36
39
  onCrawled,
37
40
  });
38
41
  const recommender = createJobRecommender({ sources: companies, store, crawl: local.crawl });
package/src/types.ts CHANGED
@@ -66,6 +66,8 @@ export interface SourceVerificationResult {
66
66
  rejected: RejectedSource[];
67
67
  }
68
68
 
69
+ import type { Experience } from "./experience.ts";
70
+
69
71
  export type WorkMode = "remote" | "hybrid" | "onsite" | "unknown";
70
72
  export type EligibilityConfidence = "explicit" | "inferred" | "unknown";
71
73
 
@@ -83,6 +85,8 @@ export interface JobSummary {
83
85
  url: string;
84
86
  /** Posting date when the board exposes one (Workday's relative label is approximate), otherwise the board's last-update time. */
85
87
  updatedAt?: string;
88
+ /** Years of experience the posting states, read from its description. null: the description states none. Absent: not read yet. */
89
+ experience?: Experience | null;
86
90
  /** Days since the posting date at the time of the search; absent when the board gave no date. */
87
91
  postedDaysAgo?: number;
88
92
  /** new = 7 days or less, older = 8 to 30, stale = beyond 30, undated = no posting date. Present it as such; a stale listing may still be open. */
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.31";
1
+ export const VERSION = "0.1.33";