careermate 0.0.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.
Files changed (137) hide show
  1. package/AGENTS.md +33 -0
  2. package/INSTALL.md +230 -0
  3. package/LICENSE +21 -0
  4. package/README.md +532 -0
  5. package/THIRD_PARTY_NOTICES.md +57 -0
  6. package/bin/careermate.mjs +77 -0
  7. package/dist/career-os/README.md +78 -0
  8. package/dist/career-os/eop/cover-letter.md +46 -0
  9. package/dist/career-os/eop/fit-analysis.md +48 -0
  10. package/dist/career-os/eop/interview-prep.md +39 -0
  11. package/dist/career-os/eop/job-analysis.md +42 -0
  12. package/dist/career-os/eop/profile-extraction.md +37 -0
  13. package/dist/career-os/knowledge/ats.md +131 -0
  14. package/dist/career-os/knowledge/company-research.md +137 -0
  15. package/dist/career-os/knowledge/cover-letter.md +135 -0
  16. package/dist/career-os/knowledge/fit-matching.md +121 -0
  17. package/dist/career-os/knowledge/human-writing.md +118 -0
  18. package/dist/career-os/knowledge/interview-behavioral.md +132 -0
  19. package/dist/career-os/knowledge/interview-technical.md +126 -0
  20. package/dist/career-os/knowledge/linkedin-profile.md +152 -0
  21. package/dist/career-os/knowledge/networking-referrals.md +166 -0
  22. package/dist/career-os/knowledge/offer-evaluation-decision.md +121 -0
  23. package/dist/career-os/knowledge/onboarding-first-90-days.md +126 -0
  24. package/dist/career-os/knowledge/portfolio.md +121 -0
  25. package/dist/career-os/knowledge/recruiter-screen.md +105 -0
  26. package/dist/career-os/knowledge/rejection-triage-iteration.md +115 -0
  27. package/dist/career-os/knowledge/resume.md +123 -0
  28. package/dist/career-os/knowledge/salary-negotiation.md +123 -0
  29. package/dist/career-os/knowledge/verifiers/ats-compat.md +64 -0
  30. package/dist/career-os/knowledge/verifiers/consistency.md +61 -0
  31. package/dist/career-os/knowledge/verifiers/human-voice.md +82 -0
  32. package/dist/career-os/knowledge/verifiers/recency-staleness.md +72 -0
  33. package/dist/career-os/knowledge/verifiers/responsiveness-on-target.md +82 -0
  34. package/dist/career-os/knowledge/verifiers/truthfulness.md +67 -0
  35. package/dist/doctor.mjs +79094 -0
  36. package/dist/mcp.mjs +96108 -0
  37. package/dist/migrate.mjs +4735 -0
  38. package/dist/pdf.worker.min.mjs +28 -0
  39. package/dist/public/app.js +150 -0
  40. package/dist/public/fonts/PretendardVariable.woff2 +0 -0
  41. package/dist/public/icon-180.png +0 -0
  42. package/dist/public/index.html +52 -0
  43. package/dist/public/lib.js +394 -0
  44. package/dist/public/pages/applications.js +107 -0
  45. package/dist/public/pages/documents.js +467 -0
  46. package/dist/public/pages/home.js +261 -0
  47. package/dist/public/pages/interview.js +227 -0
  48. package/dist/public/pages/jobs.js +493 -0
  49. package/dist/public/pages/profile.js +556 -0
  50. package/dist/public/pages/settings.js +240 -0
  51. package/dist/public/styles.css +498 -0
  52. package/dist/site/analytics.js +97 -0
  53. package/dist/site/favicon.svg +1 -0
  54. package/dist/site/fonts/PretendardVariable.woff2 +0 -0
  55. package/dist/site/icon-180.png +0 -0
  56. package/dist/site/icon-512.png +0 -0
  57. package/dist/site/index.html +598 -0
  58. package/dist/site/landing.js +54 -0
  59. package/dist/site/llms-install.txt +217 -0
  60. package/dist/site/robots.txt +4 -0
  61. package/dist/site/sitemap.xml +21 -0
  62. package/dist/site/start.html +326 -0
  63. package/dist/site/style.css +550 -0
  64. package/dist/site/vercel.json +20 -0
  65. package/dist/web.mjs +46852 -0
  66. package/docs/FAQ.md +101 -0
  67. package/docs/MCP_TOOLS.md +311 -0
  68. package/docs/README.md +28 -0
  69. package/docs/START_WORKFLOW.md +137 -0
  70. package/docs/SUPPORTED_AI_APPS.md +112 -0
  71. package/docs/WORKFLOWS.md +202 -0
  72. package/docs/career-os/README.md +78 -0
  73. package/docs/career-os/eop/cover-letter.md +46 -0
  74. package/docs/career-os/eop/fit-analysis.md +48 -0
  75. package/docs/career-os/eop/interview-prep.md +39 -0
  76. package/docs/career-os/eop/job-analysis.md +42 -0
  77. package/docs/career-os/eop/profile-extraction.md +37 -0
  78. package/docs/career-os/knowledge/ats.md +131 -0
  79. package/docs/career-os/knowledge/company-research.md +137 -0
  80. package/docs/career-os/knowledge/cover-letter.md +135 -0
  81. package/docs/career-os/knowledge/fit-matching.md +121 -0
  82. package/docs/career-os/knowledge/human-writing.md +118 -0
  83. package/docs/career-os/knowledge/interview-behavioral.md +132 -0
  84. package/docs/career-os/knowledge/interview-technical.md +126 -0
  85. package/docs/career-os/knowledge/linkedin-profile.md +152 -0
  86. package/docs/career-os/knowledge/networking-referrals.md +166 -0
  87. package/docs/career-os/knowledge/offer-evaluation-decision.md +121 -0
  88. package/docs/career-os/knowledge/onboarding-first-90-days.md +126 -0
  89. package/docs/career-os/knowledge/portfolio.md +121 -0
  90. package/docs/career-os/knowledge/recruiter-screen.md +105 -0
  91. package/docs/career-os/knowledge/rejection-triage-iteration.md +115 -0
  92. package/docs/career-os/knowledge/resume.md +123 -0
  93. package/docs/career-os/knowledge/salary-negotiation.md +123 -0
  94. package/docs/career-os/knowledge/verifiers/ats-compat.md +64 -0
  95. package/docs/career-os/knowledge/verifiers/consistency.md +61 -0
  96. package/docs/career-os/knowledge/verifiers/human-voice.md +82 -0
  97. package/docs/career-os/knowledge/verifiers/recency-staleness.md +72 -0
  98. package/docs/career-os/knowledge/verifiers/responsiveness-on-target.md +82 -0
  99. package/docs/career-os/knowledge/verifiers/truthfulness.md +67 -0
  100. package/package.json +89 -0
  101. package/packages/core/src/context.ts +74 -0
  102. package/packages/core/src/index.ts +8 -0
  103. package/packages/core/src/onboarding.ts +81 -0
  104. package/packages/core/src/services.ts +187 -0
  105. package/packages/core/src/summary.ts +104 -0
  106. package/packages/db/src/connection.ts +76 -0
  107. package/packages/db/src/index.ts +23 -0
  108. package/packages/db/src/paths.ts +52 -0
  109. package/packages/db/src/repositories.ts +891 -0
  110. package/packages/db/src/runtime.ts +58 -0
  111. package/packages/db/src/schema.ts +307 -0
  112. package/packages/exporters/src/html.ts +110 -0
  113. package/packages/exporters/src/index.ts +364 -0
  114. package/packages/exporters/src/markdown.ts +178 -0
  115. package/packages/knowledge/src/index.ts +168 -0
  116. package/packages/mcp-tools/src/bridge.ts +106 -0
  117. package/packages/mcp-tools/src/index.ts +17 -0
  118. package/packages/mcp-tools/src/result.ts +49 -0
  119. package/packages/mcp-tools/src/tools.ts +747 -0
  120. package/packages/mcp-tools/src/update.ts +330 -0
  121. package/packages/parsers/src/documents.ts +533 -0
  122. package/packages/parsers/src/html.ts +86 -0
  123. package/packages/parsers/src/index.ts +231 -0
  124. package/packages/parsers/src/keywords.ts +151 -0
  125. package/packages/prompts/src/humanize.ts +59 -0
  126. package/packages/prompts/src/index.ts +82 -0
  127. package/packages/prompts/src/install.ts +43 -0
  128. package/packages/prompts/src/onboarding.ts +35 -0
  129. package/packages/prompts/src/system.ts +57 -0
  130. package/packages/shared/src/build-info.ts +22 -0
  131. package/packages/shared/src/enums.ts +142 -0
  132. package/packages/shared/src/index.ts +19 -0
  133. package/packages/shared/src/schemas.ts +453 -0
  134. package/packages/workflows/src/definitions.ts +109 -0
  135. package/packages/workflows/src/index.ts +39 -0
  136. package/scripts/init.ts +371 -0
  137. package/tsconfig.json +32 -0
@@ -0,0 +1,533 @@
1
+ /**
2
+ * @careermate/parsers/documents — extract readable text from binary document
3
+ * files that an AI client cannot read on its own.
4
+ *
5
+ * Handled here (all pure-JS, no external binaries — bundles into dist and ships
6
+ * with the normal npm install, zero extra setup):
7
+ * - `.docx` → mammoth
8
+ * - `.hwp` → hwp.js (HWP 5.x)
9
+ * - `.hwpx` → unzip + OWPML text nodes
10
+ * - `.pdf` → pdfjs-dist (text-based PDFs; scanned/image PDFs have no text layer)
11
+ * - `.txt/.md/...` → read as UTF-8
12
+ *
13
+ * Deliberately NOT handled:
14
+ * - Images → no OCR in pure JS that's worth bundling; defer to the client's
15
+ * native image reading (Claude reads images directly).
16
+ * - Legacy `.doc/.ppt/.xls`, `.odt`, Apple iWork → no reliable pure-JS reader;
17
+ * we ask the user to re-save as PDF/DOCX.
18
+ *
19
+ * `extractDocument` never throws: a bad/unsupported file comes back as
20
+ * `unsupported: true` with a friendly Korean `warnings` message, so the MCP tool
21
+ * can relay it instead of crashing.
22
+ */
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import os from 'node:os';
26
+ import { unzipSync, strFromU8 } from 'fflate';
27
+ import mammoth from 'mammoth';
28
+ import hwpjs from 'hwp.js';
29
+ import { BUNDLED } from '@careermate/shared';
30
+ import { decodeEntities } from './html.ts';
31
+
32
+ // hwp.js ships as CJS ({ Viewer, parse }); tolerate either interop shape.
33
+ const parseHwp: ((input: unknown, opts?: unknown) => HwpDoc) | undefined =
34
+ (hwpjs as any)?.parse ?? (hwpjs as any)?.default?.parse;
35
+
36
+ /** Logical source format we detected/handled. */
37
+ export type DocumentFormat =
38
+ | 'docx'
39
+ | 'hwp'
40
+ | 'hwpx'
41
+ | 'pdf'
42
+ | 'image'
43
+ | 'text'
44
+ | 'markdown'
45
+ | 'unsupported';
46
+
47
+ export interface ExtractDocumentResult {
48
+ /** Extracted plain text (empty when unsupported). */
49
+ text: string;
50
+ format: DocumentFormat;
51
+ /** Human-facing notes/caveats in Korean (always present, may be empty). */
52
+ warnings: string[];
53
+ /** Bytes read from disk (0 when not read). */
54
+ bytes: number;
55
+ /** True when the file could not be turned into text here. */
56
+ unsupported: boolean;
57
+ /**
58
+ * True only for *operational* failures the caller should surface as an error
59
+ * (file not found, path is a directory, too large, unreadable). Soft cases —
60
+ * images, scanned PDFs, legacy/unparseable formats — are `unsupported: true`
61
+ * but `errored: false`: they carry actionable guidance, not a failure.
62
+ */
63
+ errored: boolean;
64
+ /** Absolute path we resolved the input to (for messaging). */
65
+ path: string;
66
+ }
67
+
68
+ /** Hard ceiling so a giant/mis-typed file can't blow up memory. */
69
+ const MAX_BYTES = 25 * 1024 * 1024;
70
+
71
+ /**
72
+ * Refuse to read obviously-sensitive files (private keys, credentials, secrets).
73
+ * read_document runs with the user's full filesystem privileges, and a malicious
74
+ * job posting could prompt-inject the user's agent into reading e.g. ~/.ssh/id_rsa
75
+ * "to finish the analysis". None of these are ever a résumé/career document, so
76
+ * declining them costs nothing and closes a real exfiltration amplifier.
77
+ * (`.key` is intentionally absent — it's Apple Keynote, handled as legacy-binary.)
78
+ */
79
+ const SENSITIVE_PATH_RE =
80
+ /(^|[/\\])\.(ssh|aws|gnupg)([/\\]|$)|(^|[/\\])id_(rsa|dsa|ecdsa|ed25519)(\.[\w]+)?$|\.(pem|ppk)$|(^|[/\\])\.env(\.[\w-]+)?$|(^|[/\\])\.(npmrc|netrc|git-credentials)$/i;
81
+
82
+ // Bound the work a single (possibly hostile) file can trigger. The MAX_BYTES cap
83
+ // only limits the *compressed* input; a crafted archive can still expand far
84
+ // beyond that, and a PDF can declare an enormous page count — either can hang or
85
+ // OOM the MCP process. These caps keep extraction best-effort and bounded.
86
+ const MAX_HWPX_ENTRY_BYTES = 40 * 1024 * 1024; // per decompressed body section
87
+ const MAX_HWPX_TOTAL_BYTES = 80 * 1024 * 1024; // sum across decompressed sections
88
+ const MAX_PDF_PAGES = 300; // extract at most this many pages
89
+
90
+ const DOCX_RE = /\.docx$/i;
91
+ const HWP_RE = /\.hwp$/i;
92
+ const HWPX_RE = /\.hwpx$/i;
93
+ const PDF_RE = /\.pdf$/i;
94
+ const IMAGE_RE = /\.(png|jpe?g|gif|webp|bmp|tiff?|heic|svg)$/i;
95
+ const TEXT_RE = /\.(txt|text|log|csv|tsv|json|ya?ml|xml)$/i;
96
+ const MD_RE = /\.(md|markdown|mdown|mkd)$/i;
97
+ const LEGACY_BINARY_RE = /\.(docm?|dot[xm]?|pptx?|ppt|xlsx?|xls|odt|ods|odp|rtf|pages|key|numbers)$/i;
98
+
99
+ /** Resolve `~` and relative paths to an absolute path. */
100
+ export function resolveDocumentPath(input: string, baseDir?: string): string {
101
+ let p = (input ?? '').trim().replace(/^["']|["']$/g, '');
102
+ if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) p = path.join(os.homedir(), p.slice(1));
103
+ if (!path.isAbsolute(p)) p = path.resolve(baseDir ?? process.cwd(), p);
104
+ return p;
105
+ }
106
+
107
+ /**
108
+ * Read a document file from disk and extract its text. Never throws.
109
+ *
110
+ * @param input file path (absolute, relative, or `~/…`)
111
+ * @param baseDir base for relative paths (default: process.cwd())
112
+ */
113
+ export async function extractDocument(input: string, baseDir?: string): Promise<ExtractDocumentResult> {
114
+ const filePath = resolveDocumentPath(input, baseDir);
115
+ const base = (fmt: DocumentFormat, over: Partial<ExtractDocumentResult> = {}): ExtractDocumentResult => ({
116
+ text: '',
117
+ format: fmt,
118
+ warnings: [],
119
+ bytes: 0,
120
+ unsupported: false,
121
+ errored: false,
122
+ path: filePath,
123
+ ...over,
124
+ });
125
+
126
+ // Decline secrets/credentials before touching the file (see SENSITIVE_PATH_RE).
127
+ if (SENSITIVE_PATH_RE.test(filePath)) {
128
+ return base('unsupported', {
129
+ warnings: [
130
+ '보안상 민감할 수 있는 파일(개인 키·자격증명 등)은 읽지 않습니다. 이력서·경력기술서·자기소개서 같은 문서 파일을 지정해 주세요.',
131
+ ],
132
+ unsupported: true,
133
+ errored: true,
134
+ });
135
+ }
136
+
137
+ let stat: fs.Stats;
138
+ try {
139
+ stat = fs.statSync(filePath);
140
+ } catch {
141
+ return base('unsupported', { warnings: [`파일을 찾을 수 없습니다: ${filePath}`], unsupported: true, errored: true });
142
+ }
143
+ if (stat.isDirectory())
144
+ return base('unsupported', { warnings: [`경로가 폴더입니다(파일이 아님): ${filePath}`], unsupported: true, errored: true });
145
+ if (stat.size > MAX_BYTES)
146
+ return base('unsupported', {
147
+ bytes: stat.size,
148
+ warnings: [`파일이 너무 큽니다(${(stat.size / 1048576).toFixed(1)}MB, 최대 ${MAX_BYTES / 1048576}MB).`],
149
+ unsupported: true,
150
+ errored: true,
151
+ });
152
+ // Zero-byte files: a clean, format-agnostic message instead of a silent empty
153
+ // "success" (text files) or a leaked library error ("The PDF file is empty…").
154
+ if (stat.size === 0)
155
+ return base('unsupported', {
156
+ bytes: 0,
157
+ warnings: ['파일이 비어 있습니다(0바이트). 내용이 있는 파일을 지정해 주세요.'],
158
+ unsupported: true,
159
+ });
160
+
161
+ const name = path.basename(filePath);
162
+ const bytes = stat.size;
163
+
164
+ // Images: no pure-JS OCR worth bundling — let the client read them natively.
165
+ if (IMAGE_RE.test(name)) {
166
+ return base('image', {
167
+ bytes,
168
+ warnings: [
169
+ '이미지는 이 도구로 글자를 추출하지 않습니다. AI 클라이언트가 이미지를 직접 볼 수 있으면 그렇게 읽고, 아니면 내용을 텍스트로 붙여넣어 주세요.',
170
+ ],
171
+ unsupported: true,
172
+ });
173
+ }
174
+
175
+ // Plain text / markdown. Decode by sniffing the encoding (BOM → UTF-8 →
176
+ // CP949): Korean résumés saved by Notepad/Excel are often EUC-KR/CP949, not
177
+ // UTF-8, and reading those as UTF-8 yields mojibake.
178
+ if (TEXT_RE.test(name) || MD_RE.test(name)) {
179
+ let textBuf: Buffer;
180
+ try {
181
+ textBuf = fs.readFileSync(filePath);
182
+ } catch (e) {
183
+ return base('unsupported', { bytes, warnings: [`텍스트 파일을 읽지 못했습니다: ${errMsg(e)}`], unsupported: true, errored: true });
184
+ }
185
+ const decoded = decodeText(textBuf).trim();
186
+ if (!decoded)
187
+ return base(MD_RE.test(name) ? 'markdown' : 'text', {
188
+ bytes,
189
+ warnings: ['파일에 읽을 텍스트가 없습니다(빈 파일). 내용이 있는 파일을 지정해 주세요.'],
190
+ unsupported: true,
191
+ });
192
+ return base(MD_RE.test(name) ? 'markdown' : 'text', { text: decoded, bytes });
193
+ }
194
+
195
+ // Read bytes once for binary handlers.
196
+ let buf: Buffer;
197
+ try {
198
+ buf = fs.readFileSync(filePath);
199
+ } catch (e) {
200
+ return base('unsupported', { bytes, warnings: [`파일을 읽지 못했습니다: ${errMsg(e)}`], unsupported: true, errored: true });
201
+ }
202
+
203
+ if (DOCX_RE.test(name)) return finishDocx(buf, base);
204
+ if (HWPX_RE.test(name)) return finishHwpx(buf, base);
205
+ if (HWP_RE.test(name)) return finishHwp(buf, base);
206
+ if (PDF_RE.test(name)) return finishPdf(buf, base);
207
+
208
+ // Legacy binary office formats with no reliable pure-JS reader.
209
+ if (LEGACY_BINARY_RE.test(name)) {
210
+ return base('unsupported', {
211
+ bytes,
212
+ warnings: [
213
+ `${path.extname(name) || '이 형식'}은 자동 추출을 지원하지 않습니다. PDF 또는 .docx/.hwp/.hwpx로 다시 저장하거나, 내용을 텍스트로 복사해 붙여넣어 주세요.`,
214
+ ],
215
+ unsupported: true,
216
+ });
217
+ }
218
+
219
+ // Unknown extension: try to decode as text (BOM → UTF-8 → CP949), bail if it looks binary.
220
+ const asText = decodeText(buf);
221
+ if (looksBinary(asText)) {
222
+ return base('unsupported', {
223
+ bytes,
224
+ warnings: ['지원하지 않는 형식이거나 바이너리 파일로 보입니다. PDF/.docx/.hwp/.hwpx로 저장하거나 텍스트로 붙여넣어 주세요.'],
225
+ unsupported: true,
226
+ });
227
+ }
228
+ const trimmed = asText.trim();
229
+ if (!trimmed)
230
+ return base('unsupported', {
231
+ bytes,
232
+ warnings: ['파일에 읽을 텍스트가 없습니다(빈 파일). 내용이 있는 파일을 지정해 주세요.'],
233
+ unsupported: true,
234
+ });
235
+ return base('text', { text: trimmed, bytes });
236
+ }
237
+
238
+ type BaseFn = (fmt: DocumentFormat, over?: Partial<ExtractDocumentResult>) => ExtractDocumentResult;
239
+
240
+ /* ------------------------------------------------------------------- docx */
241
+
242
+ async function finishDocx(buf: Buffer, base: BaseFn): Promise<ExtractDocumentResult> {
243
+ try {
244
+ const { value, messages } = await mammoth.extractRawText({ buffer: buf });
245
+ const text = (value ?? '').trim();
246
+ if (!text)
247
+ return base('docx', { bytes: buf.length, warnings: ['DOCX에서 텍스트를 찾지 못했습니다(이미지 위주 문서일 수 있음).'], unsupported: true });
248
+ const warnings = messages?.length ? ['일부 서식/이미지는 무시하고 본문 텍스트만 추출했습니다.'] : [];
249
+ return base('docx', { text, bytes: buf.length, warnings });
250
+ } catch (e) {
251
+ return base('unsupported', {
252
+ bytes: buf.length,
253
+ warnings: [`DOCX를 읽지 못했습니다(${errMsg(e)}). 구버전 .doc이면 .docx 또는 PDF로 다시 저장해 주세요.`],
254
+ unsupported: true,
255
+ });
256
+ }
257
+ }
258
+
259
+ /* -------------------------------------------------------------------- hwp */
260
+
261
+ interface HwpChar {
262
+ type: number;
263
+ value: number | string;
264
+ }
265
+ interface HwpParagraph {
266
+ content?: HwpChar[];
267
+ }
268
+ interface HwpSection {
269
+ content?: HwpParagraph[];
270
+ }
271
+ interface HwpDoc {
272
+ sections?: HwpSection[];
273
+ }
274
+
275
+ function finishHwp(buf: Buffer, base: BaseFn): ExtractDocumentResult {
276
+ if (typeof parseHwp !== 'function')
277
+ return base('unsupported', { bytes: buf.length, warnings: ['HWP 파서를 불러오지 못했습니다.'], unsupported: true });
278
+ try {
279
+ const doc = parseHwp(buf, { type: 'buffer' });
280
+ const text = hwpDocToText(doc);
281
+ if (!text.trim())
282
+ return base('hwp', {
283
+ bytes: buf.length,
284
+ warnings: ['HWP에서 본문 텍스트를 찾지 못했습니다(이미지 위주이거나 보호된 문서일 수 있음). PDF로 저장해 다시 시도하거나 텍스트로 붙여넣어 주세요.'],
285
+ unsupported: true,
286
+ });
287
+ return base('hwp', {
288
+ text,
289
+ bytes: buf.length,
290
+ warnings: ['HWP는 형식 특성상 표·머리말 등 일부 서식이 단순 텍스트로 펼쳐질 수 있습니다. 핵심 내용 위주로 활용하세요.'],
291
+ });
292
+ } catch (e) {
293
+ return base('unsupported', {
294
+ bytes: buf.length,
295
+ warnings: [`HWP를 읽지 못했습니다(${errMsg(e)}). 구버전·암호화·배포용 문서일 수 있습니다. PDF로 저장하거나 텍스트로 붙여넣어 주세요.`],
296
+ unsupported: true,
297
+ });
298
+ }
299
+ }
300
+
301
+ /** sections → paragraphs → chars; CharType 0 (Char) carries the text. */
302
+ function hwpDocToText(doc: HwpDoc): string {
303
+ const lines: string[] = [];
304
+ for (const section of doc.sections ?? []) {
305
+ for (const para of section.content ?? []) {
306
+ let line = '';
307
+ for (const ch of para.content ?? []) {
308
+ if (typeof ch.value === 'string') line += ch.value;
309
+ else if (ch.type === 0 && typeof ch.value === 'number' && isPrintableCodePoint(ch.value))
310
+ // fromCodePoint (not fromCharCode) so supplementary-plane chars (emoji,
311
+ // rare CJK) survive instead of being truncated to their low 16 bits.
312
+ line += String.fromCodePoint(ch.value);
313
+ }
314
+ lines.push(line.trimEnd());
315
+ }
316
+ }
317
+ return collapseBlankRuns(lines).join('\n').trim();
318
+ }
319
+
320
+ /* ------------------------------------------------------------------ hwpx */
321
+
322
+ function finishHwpx(buf: Buffer, base: BaseFn): ExtractDocumentResult {
323
+ try {
324
+ // Only decompress the body section XML — skipping embedded images/fonts/binData
325
+ // both speeds extraction and bounds a zip-bomb's expansion. Reject sections that
326
+ // declare (or together sum to) an absurd uncompressed size before inflating.
327
+ let total = 0;
328
+ const files = unzipSync(new Uint8Array(buf), {
329
+ filter: (f) => {
330
+ if (!/(^|\/)Contents\/section\d+\.xml$/i.test(f.name)) return false;
331
+ if (f.originalSize > MAX_HWPX_ENTRY_BYTES) throw new Error('HWPX 본문 항목이 비정상적으로 큽니다.');
332
+ total += f.originalSize;
333
+ if (total > MAX_HWPX_TOTAL_BYTES) throw new Error('HWPX 압축 해제 크기가 한도를 초과했습니다.');
334
+ return true;
335
+ },
336
+ });
337
+ const sections = Object.keys(files).sort();
338
+ if (sections.length === 0)
339
+ return base('unsupported', { bytes: buf.length, warnings: ['HWPX 구조에서 본문(Contents/section*.xml)을 찾지 못했습니다.'], unsupported: true });
340
+ const lines: string[] = [];
341
+ for (const name of sections) {
342
+ const xml = strFromU8(files[name]);
343
+ for (const pm of xml.split(/<hp:p[\s>]/).slice(1)) {
344
+ const para = pm.split('</hp:p>')[0];
345
+ const runs = [...para.matchAll(/<hp:t[^>]*>([\s\S]*?)<\/hp:t>/g)].map((m) => decodeEntities(m[1]));
346
+ lines.push(runs.join('').trimEnd());
347
+ }
348
+ }
349
+ const text = collapseBlankRuns(lines).join('\n').trim();
350
+ if (!text)
351
+ return base('hwpx', { bytes: buf.length, warnings: ['HWPX에서 본문 텍스트를 찾지 못했습니다. PDF로 저장하거나 텍스트로 붙여넣어 주세요.'], unsupported: true });
352
+ return base('hwpx', { text, bytes: buf.length });
353
+ } catch (e) {
354
+ return base('unsupported', {
355
+ bytes: buf.length,
356
+ warnings: [`HWPX를 읽지 못했습니다(${errMsg(e)}). 파일이 손상되었거나 형식이 다를 수 있습니다.`],
357
+ unsupported: true,
358
+ });
359
+ }
360
+ }
361
+
362
+ /* ------------------------------------------------------------------- pdf */
363
+
364
+ async function finishPdf(buf: Buffer, base: BaseFn): Promise<ExtractDocumentResult> {
365
+ try {
366
+ // Lazy import: pdfjs is heavy; only load it when a PDF actually arrives.
367
+ const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
368
+ // In the esbuild bundle, pdfjs's main is inlined into dist/*.mjs, so on load
369
+ // it auto-derives its worker path as `dist/pdf.worker.mjs` (next to the
370
+ // bundle) — which doesn't exist. We ship `dist/pdf.worker.min.mjs` instead
371
+ // (see scripts/build-dist.mjs), so override workerSrc to point at it. Must
372
+ // be unconditional: pdfjs has already self-set workerSrc by now. Under
373
+ // tsx/source pdfjs resolves its worker from node_modules, so leave it.
374
+ if (BUNDLED) {
375
+ try {
376
+ pdfjs.GlobalWorkerOptions.workerSrc = new URL('./pdf.worker.min.mjs', import.meta.url).href;
377
+ } catch {
378
+ /* keep pdfjs's own resolution */
379
+ }
380
+ }
381
+ const data = new Uint8Array(buf);
382
+ const task = pdfjs.getDocument({
383
+ data,
384
+ // Keep stdout clean (MCP protocol channel) and avoid optional asset fetches.
385
+ verbosity: 0,
386
+ useSystemFonts: false,
387
+ });
388
+
389
+ const pages: string[] = [];
390
+ let pageWarnings: string[] = [];
391
+ try {
392
+ const doc = await task.promise;
393
+ const limit = Math.min(doc.numPages, MAX_PDF_PAGES);
394
+ if (doc.numPages > MAX_PDF_PAGES)
395
+ pageWarnings = [
396
+ `페이지가 많아 앞 ${MAX_PDF_PAGES}쪽만 추출했습니다(총 ${doc.numPages}쪽). 나머지가 필요하면 해당 부분만 따로 저장하거나 텍스트로 붙여넣어 주세요.`,
397
+ ];
398
+ for (let i = 1; i <= limit; i++) {
399
+ const page = await doc.getPage(i);
400
+ const tc = await page.getTextContent();
401
+ let line = '';
402
+ for (const item of tc.items as Array<{ str?: string; hasEOL?: boolean }>) {
403
+ line += item.str ?? '';
404
+ if (item.hasEOL) line += '\n';
405
+ }
406
+ pages.push(line.replace(/[ \t]+\n/g, '\n').trim());
407
+ }
408
+ } finally {
409
+ // Always tear down the loading task (worker/transport + document), even if
410
+ // a page throws mid-parse — otherwise repeated PDF reads leak pdfjs resources.
411
+ // Best-effort: a teardown failure must not discard an already-successful extraction.
412
+ try {
413
+ await task.destroy();
414
+ } catch {
415
+ /* ignore teardown errors */
416
+ }
417
+ }
418
+ const text = collapseBlankRuns(pages.join('\n\n').split('\n')).join('\n').trim();
419
+
420
+ if (!text)
421
+ return base('pdf', {
422
+ bytes: buf.length,
423
+ warnings: [
424
+ '이 PDF에는 추출 가능한 텍스트 레이어가 없습니다(스캔본/이미지 PDF로 보임). 이미지를 직접 볼 수 있는 AI 클라이언트라면 PDF를 직접 열어 읽고, 아니면 내용을 텍스트로 붙여넣어 주세요.',
425
+ ],
426
+ unsupported: true,
427
+ });
428
+ return base('pdf', { text, bytes: buf.length, warnings: pageWarnings });
429
+ } catch (e) {
430
+ // pdfjs throws a named PasswordException for password-protected PDFs — give
431
+ // a distinct, actionable hint instead of the generic "corrupted or encrypted".
432
+ const name = e && typeof e === 'object' && 'name' in e ? String((e as { name?: unknown }).name) : '';
433
+ if (name === 'PasswordException') {
434
+ return base('unsupported', {
435
+ bytes: buf.length,
436
+ warnings: [
437
+ '이 PDF는 암호(비밀번호)로 보호되어 있어 읽을 수 없습니다. PDF 뷰어에서 암호를 해제해 다시 저장하거나, 내용을 텍스트로 붙여넣어 주세요.',
438
+ ],
439
+ unsupported: true,
440
+ });
441
+ }
442
+ return base('unsupported', {
443
+ bytes: buf.length,
444
+ warnings: [`PDF를 읽지 못했습니다(${errMsg(e)}). 손상되었거나 지원하지 않는 PDF일 수 있습니다. 텍스트로 붙여넣어 주세요.`],
445
+ unsupported: true,
446
+ });
447
+ }
448
+ }
449
+
450
+ /* ---------------------------------------------------------------- helpers */
451
+
452
+ function collapseBlankRuns(lines: string[]): string[] {
453
+ const out: string[] = [];
454
+ let blanks = 0;
455
+ for (const l of lines) {
456
+ if (l.trim() === '') {
457
+ blanks++;
458
+ if (blanks <= 1) out.push('');
459
+ } else {
460
+ blanks = 0;
461
+ out.push(l);
462
+ }
463
+ }
464
+ while (out.length && out[0] === '') out.shift();
465
+ while (out.length && out[out.length - 1] === '') out.pop();
466
+ return out;
467
+ }
468
+
469
+ /** Keep printable code points; preserve tab. Drop other C0/C1 controls. Guard fromCodePoint's valid range. */
470
+ function isPrintableCodePoint(cp: number): boolean {
471
+ return cp === 9 || (cp >= 32 && cp <= 0x10ffff);
472
+ }
473
+
474
+ /** 완성형 한글 음절 수에서 대체문자(U+FFFD, 디코드 실패 신호)를 페널티로 뺀 점수. 인코딩 선택용. */
475
+ function koreanScore(s: string): number {
476
+ let n = 0;
477
+ for (let i = 0; i < s.length; i++) {
478
+ const c = s.charCodeAt(i);
479
+ if (c >= 0xac00 && c <= 0xd7a3) n++; // 완성형 한글 음절
480
+ else if (c === 0xfffd) n -= 2; // 깨진 디코드일수록 낮은 점수
481
+ }
482
+ return n;
483
+ }
484
+
485
+ /**
486
+ * Decode a text buffer, sniffing the encoding so Korean files aren't mangled:
487
+ * 1) honor a UTF-8 / UTF-16 BOM if present;
488
+ * 2) else try strict UTF-8 (the common, correct case — return as-is on success);
489
+ * 3) else (invalid UTF-8) decide between lenient UTF-8 and CP949/EUC-KR by which
490
+ * yields more Korean: a real CP949 résumé wins as CP949, while a mostly-UTF-8
491
+ * file with one stray byte wins as lenient UTF-8 (so it isn't turned to mojibake).
492
+ * Fixes the common "Notepad/Excel saved as ANSI(CP949)" case without breaking valid UTF-8.
493
+ */
494
+ function decodeText(buf: Buffer): string {
495
+ if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf)
496
+ return new TextDecoder('utf-8').decode(buf.subarray(3));
497
+ if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) return new TextDecoder('utf-16le').decode(buf.subarray(2));
498
+ if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) return new TextDecoder('utf-16be').decode(buf.subarray(2));
499
+ try {
500
+ return new TextDecoder('utf-8', { fatal: true }).decode(buf);
501
+ } catch {
502
+ const lenient = new TextDecoder('utf-8').decode(buf); // U+FFFD 허용
503
+ let cp949: string | null = null;
504
+ try {
505
+ cp949 = new TextDecoder('euc-kr').decode(buf); // Node maps euc-kr → windows-949 (CP949 superset)
506
+ } catch {
507
+ return lenient; // euc-kr 미지원 빌드: lenient UTF-8로
508
+ }
509
+ return koreanScore(cp949) > koreanScore(lenient) ? cp949 : lenient; // 동점이면 UTF-8 우선
510
+ }
511
+ }
512
+
513
+ function looksBinary(s: string): boolean {
514
+ if (!s) return false;
515
+ const sample = s.slice(0, 2000);
516
+ let control = 0;
517
+ for (let i = 0; i < sample.length; i++) {
518
+ const c = sample.charCodeAt(i);
519
+ if (c === 0) return true;
520
+ if (c < 9 || (c > 13 && c < 32)) control++;
521
+ }
522
+ return control / sample.length > 0.1;
523
+ }
524
+
525
+ /**
526
+ * Library error → short, user-safe string. Strips URLs and library jargon links
527
+ * (e.g. mammoth/jszip's "…see https://stuk.github.io/jszip/…") and bounds length
528
+ * so the friendly Korean wrapper around it stays readable for non-developers.
529
+ */
530
+ function errMsg(e: unknown): string {
531
+ const raw = e instanceof Error ? e.message : String(e);
532
+ return raw.replace(/https?:\/\/\S+/g, '').replace(/\s+/g, ' ').trim().slice(0, 120);
533
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @careermate/parsers — HTML → plain text.
3
+ *
4
+ * A tiny, dependency-free HTML stripper. It is not a full parser; it is a
5
+ * best-effort cleaner that removes scripts/styles, converts block boundaries to
6
+ * newlines, decodes common entities, and collapses excess whitespace. It must
7
+ * never throw, even on malformed markup.
8
+ */
9
+
10
+ /** Named entities we bother to decode (the common ones in job postings). */
11
+ const NAMED_ENTITIES: Record<string, string> = {
12
+ amp: '&',
13
+ lt: '<',
14
+ gt: '>',
15
+ quot: '"',
16
+ apos: "'",
17
+ nbsp: ' ',
18
+ middot: '·',
19
+ hellip: '…',
20
+ mdash: '—',
21
+ ndash: '–',
22
+ bull: '•',
23
+ copy: '©',
24
+ reg: '®',
25
+ trade: '™',
26
+ };
27
+
28
+ /** Decode numeric (`&#123;` / `&#x7B;`) and a handful of named entities. */
29
+ export function decodeEntities(input: string): string {
30
+ return input.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, body: string) => {
31
+ if (body[0] === '#') {
32
+ const isHex = body[1] === 'x' || body[1] === 'X';
33
+ const code = parseInt(body.slice(isHex ? 2 : 1), isHex ? 16 : 10);
34
+ if (Number.isFinite(code) && code > 0 && code <= 0x10ffff) {
35
+ try {
36
+ return String.fromCodePoint(code);
37
+ } catch {
38
+ return match;
39
+ }
40
+ }
41
+ return match;
42
+ }
43
+ const named = NAMED_ENTITIES[body.toLowerCase()];
44
+ return named !== undefined ? named : match;
45
+ });
46
+ }
47
+
48
+ /** Block-level tags whose boundaries should become line breaks. */
49
+ const BLOCK_TAG = /<\/?(p|div|section|article|header|footer|li|ul|ol|tr|table|h[1-6]|blockquote|pre)\b[^>]*>/gi;
50
+
51
+ /**
52
+ * Convert an HTML string to readable plain text. Best-effort and total:
53
+ * any weird input simply yields whatever survives the regex passes.
54
+ */
55
+ export function stripHtml(html: string): string {
56
+ if (!html) return '';
57
+ let s = String(html);
58
+
59
+ // Remove script/style/head blocks entirely (including content).
60
+ s = s.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ');
61
+ s = s.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ');
62
+ s = s.replace(/<!--[\s\S]*?-->/g, ' ');
63
+
64
+ // Line breaks and horizontal rules -> newline.
65
+ s = s.replace(/<br\b[^>]*>/gi, '\n');
66
+ s = s.replace(/<hr\b[^>]*>/gi, '\n');
67
+
68
+ // Block tag boundaries -> newline (helps keep list items / paragraphs apart).
69
+ s = s.replace(BLOCK_TAG, '\n');
70
+
71
+ // Strip every remaining tag.
72
+ s = s.replace(/<\/?[^>]+>/g, ' ');
73
+
74
+ // Decode entities after tags are gone.
75
+ s = decodeEntities(s);
76
+
77
+ // Normalize whitespace: collapse spaces, trim lines, cap blank-line runs.
78
+ s = s.replace(/[ \t\f\v]+/g, ' ');
79
+ s = s
80
+ .split('\n')
81
+ .map((line) => line.trim())
82
+ .join('\n');
83
+ s = s.replace(/\n{3,}/g, '\n\n');
84
+
85
+ return s.trim();
86
+ }