openings 0.1.53 → 0.1.55

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openings",
3
- "version": "0.1.53",
3
+ "version": "0.1.55",
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/crawler.ts CHANGED
@@ -27,6 +27,18 @@ interface CrawlerOptions {
27
27
  * whose backlog is in the thousands, which 150 a night would never finish. */
28
28
  describeLimit?: number;
29
29
  describeBudgetMs?: number;
30
+ /** How old a posting may be and still be worth reading, in days. 0 reads any live role, whatever its date,
31
+ * including one whose board states no date at all. The nightly crawl keeps the 30-day default so it spends
32
+ * its small budget on what is new; the catch-up pass lifts it to reach roles still listed but long posted. */
33
+ describeMaxAgeDays?: number;
34
+ /**
35
+ * Whether the snapshot written to disk keeps the description text. A local install needs it: the snapshot is
36
+ * its only copy. A server crawl reports every source to an aggregator that stores them, so keeping a second
37
+ * copy only costs memory — on one box the file reached 1.3 GB and parsing it needed more RAM than the machine
38
+ * had, which killed the crawl for seven nights. The experience read out of each description is always kept,
39
+ * so a role already read is never fetched twice.
40
+ */
41
+ retainDescriptions?: boolean;
30
42
  pacingNow?: () => number;
31
43
  pacingSleep?: (delayMs: number) => Promise<void>;
32
44
  now?: () => Date;
@@ -66,14 +78,18 @@ export function createCrawler(options: CrawlerOptions): Crawler {
66
78
  /** Required experience for every job: from its description, the last crawl's reading, or a paced detail fetch for recent roles in describeCountries. */
67
79
  async function withExperience(source: Company, jobs: Job[], previous: Job[] | undefined): Promise<Job[]> {
68
80
  const known = new Map((previous ?? []).map(normalizeJobExperience).filter((job) => job.experienceVersion === EXPERIENCE_VERSION && job.experience !== undefined).map((job) => [job.id, job]));
69
- const since = now().getTime() - 30 * 86_400_000;
81
+ const maxAgeDays = options.describeMaxAgeDays ?? DESCRIBE_MAX_AGE_DAYS;
82
+ const since = maxAgeDays > 0 ? now().getTime() - maxAgeDays * 86_400_000 : 0;
83
+ // An undated posting is still a live posting. Inside a window it cannot be placed, so it waits; with the
84
+ // window lifted there is no reason to leave it unread.
85
+ const recentEnough = (job: Job) => (maxAgeDays <= 0 ? true : Date.parse(job.updatedAt ?? "") >= since);
70
86
  const out: Job[] = [];
71
87
  const pending: number[] = [];
72
88
  for (const job of jobs) {
73
89
  if (job.description.trim() || job.experienceVersion === EXPERIENCE_VERSION) out.push(normalizeJobExperience(job));
74
90
  else if (known.has(job.id)) out.push({ ...job, description: known.get(job.id)!.description, experience: known.get(job.id)!.experience, experienceVersion: EXPERIENCE_VERSION });
75
91
  else {
76
- if (options.describe && describeRank(job) < describeOrder.length && Date.parse(job.updatedAt ?? "") >= since) pending.push(out.length);
92
+ if (options.describe && describeRank(job) < describeOrder.length && recentEnough(job)) pending.push(out.length);
77
93
  out.push(normalizeJobExperience(job));
78
94
  }
79
95
  }
@@ -182,7 +198,9 @@ export function createCrawler(options: CrawlerOptions): Crawler {
182
198
  cached: considered - eligibleSources.length, deferred: eligibleSources.length - selectedSources.length,
183
199
  succeeded, failed: finalFailures, sources: selectedSources.map((source) => metrics.get(source.slug)!),
184
200
  };
185
- await options.store.write({ version: 1, updatedAt: finishedAt, partitions, lastCrawl: report });
201
+ // Reporting below still sends the full partitions; only the copy kept on disk is lightened.
202
+ const stored = options.retainDescriptions === false ? withoutDescriptions(partitions) : partitions;
203
+ await options.store.write({ version: 1, updatedAt: finishedAt, partitions: stored, lastCrawl: report });
186
204
  if (options.onCrawled) {
187
205
  const crawled = selectedSources.filter((source) => metrics.get(source.slug)!.status === "succeeded");
188
206
  // Four at a time: sending a thousand reports at once made the largest ones time out while the aggregator queued them.
@@ -196,8 +214,21 @@ export function createCrawler(options: CrawlerOptions): Crawler {
196
214
  }
197
215
 
198
216
  // ponytail: per-source caps keep one crawl bounded; a big backlog (first run) drains over a few nights.
217
+ /**
218
+ * The same partitions with the description text left out. Everything the next crawl needs to avoid re-reading a
219
+ * description — the experience and its version — is kept, so this costs nothing but the bytes.
220
+ */
221
+ function withoutDescriptions(partitions: JobSnapshot["partitions"]): JobSnapshot["partitions"] {
222
+ const out: JobSnapshot["partitions"] = {};
223
+ for (const [slug, partition] of Object.entries(partitions)) {
224
+ out[slug] = { ...partition, jobs: partition.jobs.map((job) => (job.description ? { ...job, description: "" } : job)) };
225
+ }
226
+ return out;
227
+ }
228
+
199
229
  /** Defaults for the nightly crawl, where the point is to keep every source moving rather than finish any one. */
200
230
  const DESCRIBE_LIMIT = 150;
231
+ const DESCRIBE_MAX_AGE_DAYS = 30;
201
232
  const DESCRIBE_BUDGET_MS = 180_000;
202
233
 
203
234
  function partitionTime(value: string | undefined): number {
package/src/local-jobs.ts CHANGED
@@ -19,6 +19,8 @@ interface LocalJobsOptions {
19
19
  describeCountries?: string[];
20
20
  describeLimit?: number;
21
21
  describeBudgetMs?: number;
22
+ describeMaxAgeDays?: number;
23
+ retainDescriptions?: boolean;
22
24
  now?: () => Date;
23
25
  onCrawled?(source: Company, partition: JobPartition): void | Promise<void>;
24
26
  }
package/src/runtime.ts CHANGED
@@ -40,6 +40,9 @@ export function createRuntime(options: { dataDir?: string; concurrency?: number;
40
40
  // The description catch-up pass raises these; unset, the nightly crawl keeps the crawler's own small defaults.
41
41
  describeLimit: positiveEnv("OPENINGS_DESCRIBE_LIMIT"),
42
42
  describeBudgetMs: positiveEnv("OPENINGS_DESCRIBE_BUDGET_MS"),
43
+ describeMaxAgeDays: wholeEnv("OPENINGS_DESCRIBE_MAX_AGE_DAYS"),
44
+ // Server crawls set this off: the aggregator they report to is the system of record for descriptions.
45
+ retainDescriptions: process.env.OPENINGS_RETAIN_DESCRIPTIONS !== "off",
43
46
  onCrawled,
44
47
  });
45
48
  const recommender = createJobRecommender({ sources: companies, store, crawl: local.crawl });
@@ -107,3 +110,11 @@ function positiveEnv(name: string): number | undefined {
107
110
  const value = Number(raw);
108
111
  return Number.isInteger(value) && value > 0 ? value : undefined;
109
112
  }
113
+
114
+ /** A whole number from the environment, zero included, or undefined when unset or nonsense. */
115
+ function wholeEnv(name: string): number | undefined {
116
+ const raw = process.env[name];
117
+ if (raw === undefined) return undefined;
118
+ const value = Number(raw);
119
+ return Number.isInteger(value) && value >= 0 ? value : undefined;
120
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.1.53";
1
+ export const VERSION = "0.1.55";