@gafj/gafj 0.1.19 → 0.1.20

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/http/guard.js CHANGED
@@ -91,7 +91,7 @@ function readJson(req, cap = BODY_CAP) {
91
91
  const chunks = [];
92
92
  req.on("data", (c) => {
93
93
  size += c.length;
94
- if (size > cap) { reject(Object.assign(new Error("body larger than 1 MB"), { status: 413 })); req.destroy(); return; }
94
+ if (size > cap) { req.removeAllListeners("data"); req.resume(); reject(Object.assign(new Error(`body larger than ${Math.round(cap / 1024 / 1024)} MB; paste less`), { status: 413 })); return; }
95
95
  chunks.push(c);
96
96
  });
97
97
  req.on("end", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gafj/gafj",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "GAF-J: a local campaign engine for job search. One SQLite file, one ingest door, your own AI subscription.",
5
5
  "homepage": "https://gaf-j.com",
6
6
  "repository": {
package/ui/lib.js CHANGED
@@ -90,3 +90,38 @@ export function useInstall() {
90
90
  const standalone = matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
91
91
  return { canInstall: !!installer.event, installed: installer.installed || standalone, install: async () => { if (!installer.event) return; installer.event.prompt(); await installer.event.userChoice; installer.event = null; } };
92
92
  }
93
+
94
+ /**
95
+ * A job posting as pasted: markdown links become their text, bare URLs and tracking lines go, blank runs
96
+ * collapse. A LinkedIn copy carries a wall of overlay URLs that says nothing about the job.
97
+ */
98
+ export function cleanPosting(text) {
99
+ return String(text || "")
100
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, "")
101
+ .replace(/\[([^\]]*)\]\((?:https?:)?\/\/[^)\s]*(?:\s+"[^"]*")?\)/g, "$1")
102
+ .replace(/\((?:https?:)?\/\/[^)\s]{20,}\)/g, "")
103
+ .split(/\r?\n/)
104
+ .map((l) => l.replace(/\s+$/, ""))
105
+ .filter((l, i, all) => !/^\s*(?:https?:)?\/\/\S+\s*$/.test(l) || (/\/(jobs?|careers?|positions?|openings?)\//i.test(l) && all.findIndex((x) => /^\s*(?:https?:)?\/\/\S+\s*$/.test(x) && /\/(jobs?|careers?|positions?|openings?)\//i.test(x)) === i))
106
+ .join("\n")
107
+ .replace(/[ \t]{3,}/g, " ")
108
+ .replace(/\n{3,}/g, "\n\n")
109
+ .trim();
110
+ }
111
+
112
+ const NOISE = /\b(ago|hours?|days?|weeks?|applicants?|promoted|easy apply|reposted|save|share|show more|about the job|job details|united states|remote|hybrid|on-site|full-time|part-time|contract|tailor my resume|help me stand out|create cover letter)\b/i;
113
+ const TITLE = /\b(director|manager|engineer|analyst|lead|head|vp|vice president|specialist|coordinator|supervisor|associate|consultant|architect|designer|developer|scientist|officer|president|chief|planner|buyer|controller|administrator|technician|representative|recruiter|nurse|teacher|intern|accountant|assistant|generalist|partner|strategist|writer|editor)\b/i;
114
+ /** Title and company guessed from a posting's first lines; both are the person's to correct. */
115
+ export function guessPosting(text) {
116
+ const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, 20);
117
+ const plain = (l) => l.length <= 90 && !/^https?:/i.test(l) && !/^(at|company|location|posted|apply|share)\b/i.test(l) && !NOISE.test(l);
118
+ const title = lines.find((l) => plain(l) && TITLE.test(l) && !/[.:;]$/.test(l) && !/\b(company|inc|llc|corp|group|ltd)\.?$/i.test(l)) || lines.find(plain) || "";
119
+ let company = "";
120
+ for (const l of lines) { const m = l.match(/^(?:at|company[:\s]+|employer[:\s]+)\s*([^|·,]{2,60})/i); if (m) { company = m[1].trim(); break; } }
121
+ const i = lines.findIndex((l) => l === title);
122
+ const nameish = (l) => { const c = l.split(/\s[·|]\s/)[0].replace(/^[·|\-\s]+/, "").trim(); return c && c !== title && c.length <= 60 && !/^https?:/i.test(c) && !/\d{4}/.test(c) && !NOISE.test(c) && !/[.:;]$/.test(c) && c.split(/\s+/).length <= 8 ? c : ""; };
123
+ // the line after the title ("Company · City"), else a short name-like line above it, else one soon after
124
+ if (!company) company = [lines[i + 1] || ""].map(nameish).find(Boolean) || lines.slice(0, i).map(nameish).find(Boolean) || lines.slice(i + 2, i + 6).map(nameish).find(Boolean) || "";
125
+ const url = lines.find((l) => /^https?:\/\//i.test(l)) || "";
126
+ return { title, company, url };
127
+ }
@@ -1,5 +1,5 @@
1
1
  import { html, useState, useEffect } from "../vendor/preact.mjs";
2
- import { api } from "../lib.js";
2
+ import { cleanPosting, guessPosting, api } from "../lib.js";
3
3
 
4
4
  /** Screen 2: paste a posting, store and scan locally, edit the proposed categories, freeze, log the application. */
5
5
 
@@ -39,21 +39,13 @@ export function Postings({ live, id }) {
39
39
  const [form, setForm] = useState({ company: "", title: "", source_url: "", location: "", comp_text: "", raw_text: "" });
40
40
  const [guessed, setGuessed] = useState({ company: true, title: true });
41
41
  // the paste is all a person should have to give; company and title are guessed from its first lines and can be corrected
42
- const onPaste = (text) => {
43
- const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, 12);
42
+ const onPaste = (raw) => {
43
+ const text = cleanPosting(raw);
44
+ const g = guessPosting(text);
44
45
  const next = { ...form, raw_text: text };
45
- if (guessed.title) {
46
- const t = lines.find((l) => l.length <= 90 && !/^https?:/i.test(l) && !/^(at|company|location|posted|apply|share)\b/i.test(l));
47
- next.title = t || "";
48
- }
49
- if (guessed.company) {
50
- let c = "";
51
- for (const l of lines) { const m = l.match(/^(?:at|company[:\s]+|employer[:\s]+)\s*([^|·,]{2,60})/i); if (m) { c = m[1].trim(); break; } }
52
- if (!c) { const i = lines.findIndex((l) => l === next.title); const cand = lines[i + 1] || ""; if (cand && cand.length <= 50 && !/^https?:/i.test(cand) && !/\d{4}/.test(cand)) c = cand.replace(/^[·|\-\s]+/, ""); }
53
- next.company = c;
54
- }
55
- const url = lines.find((l) => /^https?:\/\//i.test(l));
56
- if (url && !form.source_url) next.source_url = url;
46
+ if (guessed.title) next.title = g.title;
47
+ if (guessed.company) next.company = g.company;
48
+ if (g.url && !form.source_url) next.source_url = g.url;
57
49
  setForm(next);
58
50
  };
59
51
  const [err, setErr] = useState("");
@@ -1,5 +1,5 @@
1
1
  import { html, useState, useEffect } from "../vendor/preact.mjs";
2
- import { api } from "../lib.js";
2
+ import { api, cleanPosting, guessPosting } from "../lib.js";
3
3
  import { Onboarding, DraftForm, fromDraft, Positions, ContactForm } from "./kb.js";
4
4
  import { PacketModal } from "./packet.js";
5
5
 
@@ -64,13 +64,12 @@ function StepPosting({ reload }) {
64
64
  const [title, setTitle] = useState("");
65
65
  const [g, setG] = useState({ company: true, title: true });
66
66
  const [err, setErr] = useState("");
67
- const onPaste = (t) => {
67
+ const onPaste = (raw) => {
68
+ const t = cleanPosting(raw);
68
69
  setText(t);
69
- const lines = t.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, 12);
70
- let ti = title, co = company;
71
- if (g.title) ti = lines.find((l) => l.length <= 90 && !/^https?:/i.test(l) && !/^(at|company|location|posted|apply|share)\b/i.test(l)) || "";
72
- if (g.company) { co = ""; for (const l of lines) { const m = l.match(/^(?:at|company[:\s]+|employer[:\s]+)\s*([^|·,]{2,60})/i); if (m) { co = m[1].trim(); break; } } if (!co) { const i = lines.findIndex((l) => l === ti); const c = lines[i + 1] || ""; if (c && c.length <= 50 && !/^https?:/i.test(c) && !/\d{4}/.test(c)) co = c.replace(/^[·|\-\s]+/, ""); } }
73
- setTitle(ti); setCompany(co);
70
+ const gu = guessPosting(t);
71
+ if (g.title) setTitle(gu.title);
72
+ if (g.company) setCompany(gu.company);
74
73
  };
75
74
  const store = async (e) => {
76
75
  e.preventDefault(); setErr("");