@happyvertical/documents 0.80.0 → 0.80.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.
@@ -1,21 +1,21 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, mkdirSync, copyFileSync } from "node:fs";
3
2
  import { dirname, join } from "node:path";
4
3
  import { fileURLToPath } from "node:url";
5
- const Dirname = dirname(fileURLToPath(import.meta.url));
6
- const pkgRoot = join(Dirname, "../..");
7
- const targetDir = join(process.cwd(), ".claude");
8
- if (!existsSync(targetDir)) {
9
- mkdirSync(targetDir, { recursive: true });
10
- }
11
- const pkgName = "documents";
12
- const agentMdSrc = existsSync(join(pkgRoot, "AGENT.md")) ? join(pkgRoot, "AGENT.md") : join(pkgRoot, "CLAUDE.md");
13
- const metaSrc = existsSync(join(pkgRoot, "metadata.json")) ? join(pkgRoot, "metadata.json") : join(pkgRoot, ".claude-meta.json");
14
- if (existsSync(agentMdSrc)) {
15
- copyFileSync(agentMdSrc, join(targetDir, `have-${pkgName}.md`));
16
- }
17
- if (existsSync(metaSrc)) {
18
- copyFileSync(metaSrc, join(targetDir, `have-${pkgName}.meta.json`));
19
- }
4
+ import { copyFileSync, existsSync, mkdirSync } from "node:fs";
5
+ //#region src/cli/claude-context.ts
6
+ /**
7
+ * CLI script to install agent context for @happyvertical/documents
8
+ * Run the published context installer binary for this package.
9
+ */
10
+ var pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
11
+ var targetDir = join(process.cwd(), ".claude");
12
+ if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
13
+ var pkgName = "documents";
14
+ var agentMdSrc = existsSync(join(pkgRoot, "AGENT.md")) ? join(pkgRoot, "AGENT.md") : join(pkgRoot, "CLAUDE.md");
15
+ var metaSrc = existsSync(join(pkgRoot, "metadata.json")) ? join(pkgRoot, "metadata.json") : join(pkgRoot, ".claude-meta.json");
16
+ if (existsSync(agentMdSrc)) copyFileSync(agentMdSrc, join(targetDir, `have-${pkgName}.md`));
17
+ if (existsSync(metaSrc)) copyFileSync(metaSrc, join(targetDir, `have-${pkgName}.meta.json`));
20
18
  console.log(`✓ Installed @happyvertical/${pkgName} context to .claude/`);
21
- //# sourceMappingURL=claude-context.js.map
19
+ //#endregion
20
+
21
+ //# sourceMappingURL=claude-context.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"claude-context.js","sources":["../../src/cli/claude-context.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * CLI script to install agent context for @happyvertical/documents\n * Run the published context installer binary for this package.\n */\nimport { copyFileSync, existsSync, mkdirSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst Dirname = dirname(fileURLToPath(import.meta.url));\nconst pkgRoot = join(Dirname, '../..');\nconst targetDir = join(process.cwd(), '.claude');\n\nif (!existsSync(targetDir)) {\n mkdirSync(targetDir, { recursive: true });\n}\n\nconst pkgName = 'documents';\nconst agentMdSrc = existsSync(join(pkgRoot, 'AGENT.md'))\n ? join(pkgRoot, 'AGENT.md')\n : join(pkgRoot, 'CLAUDE.md');\nconst metaSrc = existsSync(join(pkgRoot, 'metadata.json'))\n ? join(pkgRoot, 'metadata.json')\n : join(pkgRoot, '.claude-meta.json');\n\nif (existsSync(agentMdSrc)) {\n copyFileSync(agentMdSrc, join(targetDir, `have-${pkgName}.md`));\n}\n\nif (existsSync(metaSrc)) {\n copyFileSync(metaSrc, join(targetDir, `have-${pkgName}.meta.json`));\n}\n\nconsole.log(`✓ Installed @happyvertical/${pkgName} context to .claude/`);\n"],"names":[],"mappings":";;;;AASA,MAAM,UAAU,QAAQ,cAAc,YAAY,GAAG,CAAC;AACtD,MAAM,UAAU,KAAK,SAAS,OAAO;AACrC,MAAM,YAAY,KAAK,QAAQ,IAAA,GAAO,SAAS;AAE/C,IAAI,CAAC,WAAW,SAAS,GAAG;AAC1B,YAAU,WAAW,EAAE,WAAW,KAAA,CAAM;AAC1C;AAEA,MAAM,UAAU;AAChB,MAAM,aAAa,WAAW,KAAK,SAAS,UAAU,CAAC,IACnD,KAAK,SAAS,UAAU,IACxB,KAAK,SAAS,WAAW;AAC7B,MAAM,UAAU,WAAW,KAAK,SAAS,eAAe,CAAC,IACrD,KAAK,SAAS,eAAe,IAC7B,KAAK,SAAS,mBAAmB;AAErC,IAAI,WAAW,UAAU,GAAG;AAC1B,eAAa,YAAY,KAAK,WAAW,QAAQ,OAAO,KAAK,CAAC;AAChE;AAEA,IAAI,WAAW,OAAO,GAAG;AACvB,eAAa,SAAS,KAAK,WAAW,QAAQ,OAAO,YAAY,CAAC;AACpE;AAEA,QAAQ,IAAI,8BAA8B,OAAO,sBAAsB;"}
1
+ {"version":3,"file":"claude-context.js","names":[],"sources":["../../src/cli/claude-context.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * CLI script to install agent context for @happyvertical/documents\n * Run the published context installer binary for this package.\n */\nimport { copyFileSync, existsSync, mkdirSync } from 'node:fs';\nimport { dirname, join } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst Dirname = dirname(fileURLToPath(import.meta.url));\nconst pkgRoot = join(Dirname, '../..');\nconst targetDir = join(process.cwd(), '.claude');\n\nif (!existsSync(targetDir)) {\n mkdirSync(targetDir, { recursive: true });\n}\n\nconst pkgName = 'documents';\nconst agentMdSrc = existsSync(join(pkgRoot, 'AGENT.md'))\n ? join(pkgRoot, 'AGENT.md')\n : join(pkgRoot, 'CLAUDE.md');\nconst metaSrc = existsSync(join(pkgRoot, 'metadata.json'))\n ? join(pkgRoot, 'metadata.json')\n : join(pkgRoot, '.claude-meta.json');\n\nif (existsSync(agentMdSrc)) {\n copyFileSync(agentMdSrc, join(targetDir, `have-${pkgName}.md`));\n}\n\nif (existsSync(metaSrc)) {\n copyFileSync(metaSrc, join(targetDir, `have-${pkgName}.meta.json`));\n}\n\nconsole.log(`✓ Installed @happyvertical/${pkgName} context to .claude/`);\n"],"mappings":";;;;;;;;;AAUA,IAAM,UAAU,KADA,QAAQ,cAAc,OAAO,KAAK,GAAG,CAChC,GAAS,OAAO;AACrC,IAAM,YAAY,KAAK,QAAQ,IAAI,GAAG,SAAS;AAE/C,IAAI,CAAC,WAAW,SAAS,GACvB,UAAU,WAAW,EAAE,WAAW,KAAK,CAAC;AAG1C,IAAM,UAAU;AAChB,IAAM,aAAa,WAAW,KAAK,SAAS,UAAU,CAAC,IACnD,KAAK,SAAS,UAAU,IACxB,KAAK,SAAS,WAAW;AAC7B,IAAM,UAAU,WAAW,KAAK,SAAS,eAAe,CAAC,IACrD,KAAK,SAAS,eAAe,IAC7B,KAAK,SAAS,mBAAmB;AAErC,IAAI,WAAW,UAAU,GACvB,aAAa,YAAY,KAAK,WAAW,QAAQ,QAAQ,IAAI,CAAC;AAGhE,IAAI,WAAW,OAAO,GACpB,aAAa,SAAS,KAAK,WAAW,QAAQ,QAAQ,WAAW,CAAC;AAGpE,QAAQ,IAAI,8BAA8B,QAAQ,qBAAqB"}
package/dist/index.js CHANGED
@@ -1,322 +1,358 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
3
  import { URL as URL$1 } from "node:url";
4
- import { getMimeType, downloadFileWithCache, getCached, setCached } from "@happyvertical/files";
4
+ import { downloadFileWithCache, getCached, getMimeType, setCached } from "@happyvertical/files";
5
5
  import { makeSlug } from "@happyvertical/utils";
6
6
  import { scrapeDocument } from "@happyvertical/spider";
7
7
  import { promises } from "node:fs";
8
8
  import { getPDFReader } from "@happyvertical/pdf";
9
9
  import { v4 } from "uuid";
10
- class Document {
11
- /**
12
- * Flag indicating if document is from a remote source
13
- */
14
- isRemote = false;
15
- /**
16
- * Configuration options
17
- */
18
- options;
19
- /**
20
- * Local file path where document is stored
21
- */
22
- _localPath = "";
23
- /**
24
- * Directory used for caching files
25
- */
26
- _cacheDir = "";
27
- /**
28
- * Document URL
29
- */
30
- url;
31
- /**
32
- * Document MIME type
33
- */
34
- type;
35
- /**
36
- * Document parts (hierarchical structure)
37
- */
38
- parts = [];
39
- /**
40
- * Document-level metadata
41
- */
42
- metadata = {};
43
- /**
44
- * Get the local file path where document is stored
45
- */
46
- get localPath() {
47
- return this._localPath;
48
- }
49
- /**
50
- * Get the directory used for caching files
51
- */
52
- get cacheDir() {
53
- return this._cacheDir;
54
- }
55
- /**
56
- * Creates a new Document instance
57
- *
58
- * @param url - Document URL or file path
59
- * @param options - Document configuration options
60
- */
61
- constructor(url, options = {}) {
62
- this.url = new URL$1(url);
63
- this.options = options;
64
- this.type = options.type || getMimeType(this.url.toString()) || "text/plain";
65
- this._cacheDir = options.cacheDir || path.resolve(os.tmpdir(), ".cache", "have-sdk", "documents");
66
- if (this.url.protocol.startsWith("file")) {
67
- this._localPath = decodeURIComponent(this.url.pathname);
68
- this.isRemote = false;
69
- } else if (this.url.protocol.startsWith("http")) {
70
- let pathname = this.url.pathname;
71
- if (pathname.endsWith("/")) {
72
- pathname = pathname.slice(0, -1);
73
- }
74
- if (!pathname.match(/\.[a-z0-9]+$/i)) {
75
- if (this.type === "application/pdf" || options.type === "application/pdf") {
76
- pathname += ".pdf";
77
- }
78
- }
79
- this._localPath = path.join(
80
- this._cacheDir,
81
- makeSlug(this.url.hostname),
82
- pathname
83
- );
84
- this.isRemote = true;
85
- }
86
- }
87
- /**
88
- * Creates and initializes a Document instance
89
- *
90
- * Downloads remote files and prepares the document for processing.
91
- *
92
- * @param url - Document URL or file path
93
- * @param options - Document configuration options
94
- * @returns Promise resolving to the initialized Document
95
- */
96
- static async create(url, options = {}) {
97
- const document = new Document(url, options);
98
- await document.initialize();
99
- return document;
100
- }
101
- /**
102
- * Initializes the document, downloading it if it's remote
103
- *
104
- * @returns Promise that resolves when initialization is complete
105
- */
106
- async initialize() {
107
- if (this.isRemote) {
108
- if (!this.url) {
109
- throw new Error("Cannot initialize remote document: URL is required");
110
- }
111
- await downloadFileWithCache(this.url.toString(), this._localPath);
112
- }
113
- }
114
- /**
115
- * Checks if the document is a text-based file that can be read directly
116
- *
117
- * @returns Boolean indicating if the file is text-based
118
- */
119
- isTextFile() {
120
- if (!this.type) return false;
121
- return this.type.startsWith("text/") || this.type === "application/json" || this.type === "application/xml" || this.type === "application/javascript" || this.type === "application/typescript" || [
122
- ".txt",
123
- ".md",
124
- ".json",
125
- ".xml",
126
- ".html",
127
- ".css",
128
- ".js",
129
- ".ts",
130
- ".yaml",
131
- ".yml"
132
- ].some((ext) => this.localPath.toLowerCase().endsWith(ext));
133
- }
134
- /**
135
- * Converts the document to the standard Document interface
136
- *
137
- * @returns Document object with URL, type, parts, and metadata
138
- */
139
- toDocument() {
140
- return {
141
- url: this.url.toString(),
142
- type: this.type,
143
- parts: this.parts,
144
- metadata: this.metadata
145
- };
146
- }
147
- }
10
+ //#region src/document.ts
11
+ /**
12
+ * Base document handler with multi-part support
13
+ *
14
+ * Provides functionality for downloading, caching, and structuring documents
15
+ * into hierarchical parts. Specific format processing (PDF, HTML, Markdown)
16
+ * is handled by specialized processors.
17
+ */
18
+ var Document = class Document {
19
+ /**
20
+ * Flag indicating if document is from a remote source
21
+ */
22
+ isRemote = false;
23
+ /**
24
+ * Configuration options
25
+ */
26
+ options;
27
+ /**
28
+ * Local file path where document is stored
29
+ */
30
+ _localPath = "";
31
+ /**
32
+ * Directory used for caching files
33
+ */
34
+ _cacheDir = "";
35
+ /**
36
+ * Document URL
37
+ */
38
+ url;
39
+ /**
40
+ * Document MIME type
41
+ */
42
+ type;
43
+ /**
44
+ * Document parts (hierarchical structure)
45
+ */
46
+ parts = [];
47
+ /**
48
+ * Document-level metadata
49
+ */
50
+ metadata = {};
51
+ /**
52
+ * Get the local file path where document is stored
53
+ */
54
+ get localPath() {
55
+ return this._localPath;
56
+ }
57
+ /**
58
+ * Get the directory used for caching files
59
+ */
60
+ get cacheDir() {
61
+ return this._cacheDir;
62
+ }
63
+ /**
64
+ * Creates a new Document instance
65
+ *
66
+ * @param url - Document URL or file path
67
+ * @param options - Document configuration options
68
+ */
69
+ constructor(url, options = {}) {
70
+ this.url = new URL$1(url);
71
+ this.options = options;
72
+ this.type = options.type || getMimeType(this.url.toString()) || "text/plain";
73
+ this._cacheDir = options.cacheDir || path.resolve(os.tmpdir(), ".cache", "have-sdk", "documents");
74
+ if (this.url.protocol.startsWith("file")) {
75
+ this._localPath = decodeURIComponent(this.url.pathname);
76
+ this.isRemote = false;
77
+ } else if (this.url.protocol.startsWith("http")) {
78
+ let pathname = this.url.pathname;
79
+ if (pathname.endsWith("/")) pathname = pathname.slice(0, -1);
80
+ if (!pathname.match(/\.[a-z0-9]+$/i)) {
81
+ if (this.type === "application/pdf" || options.type === "application/pdf") pathname += ".pdf";
82
+ }
83
+ this._localPath = path.join(this._cacheDir, makeSlug(this.url.hostname), pathname);
84
+ this.isRemote = true;
85
+ }
86
+ }
87
+ /**
88
+ * Creates and initializes a Document instance
89
+ *
90
+ * Downloads remote files and prepares the document for processing.
91
+ *
92
+ * @param url - Document URL or file path
93
+ * @param options - Document configuration options
94
+ * @returns Promise resolving to the initialized Document
95
+ */
96
+ static async create(url, options = {}) {
97
+ const document = new Document(url, options);
98
+ await document.initialize();
99
+ return document;
100
+ }
101
+ /**
102
+ * Initializes the document, downloading it if it's remote
103
+ *
104
+ * @returns Promise that resolves when initialization is complete
105
+ */
106
+ async initialize() {
107
+ if (this.isRemote) {
108
+ if (!this.url) throw new Error("Cannot initialize remote document: URL is required");
109
+ await downloadFileWithCache(this.url.toString(), this._localPath);
110
+ }
111
+ }
112
+ /**
113
+ * Checks if the document is a text-based file that can be read directly
114
+ *
115
+ * @returns Boolean indicating if the file is text-based
116
+ */
117
+ isTextFile() {
118
+ if (!this.type) return false;
119
+ return this.type.startsWith("text/") || this.type === "application/json" || this.type === "application/xml" || this.type === "application/javascript" || this.type === "application/typescript" || [
120
+ ".txt",
121
+ ".md",
122
+ ".json",
123
+ ".xml",
124
+ ".html",
125
+ ".css",
126
+ ".js",
127
+ ".ts",
128
+ ".yaml",
129
+ ".yml"
130
+ ].some((ext) => this.localPath.toLowerCase().endsWith(ext));
131
+ }
132
+ /**
133
+ * Converts the document to the standard Document interface
134
+ *
135
+ * @returns Document object with URL, type, parts, and metadata
136
+ */
137
+ toDocument() {
138
+ return {
139
+ url: this.url.toString(),
140
+ type: this.type,
141
+ parts: this.parts,
142
+ metadata: this.metadata
143
+ };
144
+ }
145
+ };
146
+ //#endregion
147
+ //#region src/utils.ts
148
+ /**
149
+ * Utility functions for document processing
150
+ */
151
+ /**
152
+ * Extract a human-readable title from a URL
153
+ *
154
+ * Takes a URL and extracts the filename from the pathname, then formats it
155
+ * into a readable title by removing the extension and converting separators
156
+ * to spaces. Also decodes URL-encoded characters like %20.
157
+ *
158
+ * @param url - URL string to extract title from
159
+ * @param defaultTitle - Default title to use if extraction fails
160
+ * @returns Formatted title string
161
+ *
162
+ * @example
163
+ * ```typescript
164
+ * getTitleFromUrl('file:///path/to/My%20Document.pdf')
165
+ * // Returns: 'My Document'
166
+ *
167
+ * getTitleFromUrl('https://example.com/research_paper.pdf')
168
+ * // Returns: 'research paper'
169
+ * ```
170
+ */
148
171
  function getTitleFromUrl(url, defaultTitle = "Document") {
149
- try {
150
- const urlObj = new URL(url);
151
- const pathname = urlObj.pathname;
152
- const filename = pathname.split("/").pop() || defaultTitle;
153
- const decodedFilename = decodeURIComponent(filename);
154
- return decodedFilename.replace(/\.(pdf|html?|md|txt)$/i, "").replace(/[-_]/g, " ").trim();
155
- } catch {
156
- return defaultTitle;
157
- }
158
- }
159
- class PDFProcessor {
160
- /**
161
- * Check if this processor supports the given MIME type or extension.
162
- * Accepts `'application/pdf'`, `'.pdf'`, or `'pdf'` (case-insensitive).
163
- *
164
- * @param type - MIME type or file extension to check
165
- * @returns `true` if this processor can handle the given type
166
- */
167
- supports(type) {
168
- return type === "application/pdf" || type.endsWith(".pdf") || type.toLowerCase() === "pdf";
169
- }
170
- /**
171
- * Process a PDF document
172
- *
173
- * Extracts text and optionally images/OCR from the PDF, structuring
174
- * it into hierarchical document parts.
175
- *
176
- * @param url - PDF URL or file path
177
- * @param options - Processing options
178
- * @returns Promise resolving to structured Document
179
- */
180
- async process(url, options = {}) {
181
- const baseDoc = await Document.create(url, options);
182
- const cacheKey = `${baseDoc.localPath}.processed_pdf`;
183
- const cached = await getCached(cacheKey);
184
- if (cached) {
185
- try {
186
- const parsed = JSON.parse(cached);
187
- return {
188
- url: baseDoc.url.toString(),
189
- type: baseDoc.type,
190
- parts: parsed.parts,
191
- metadata: parsed.metadata || {}
192
- };
193
- } catch (error) {
194
- console.warn("Cached PDF data corrupted, reprocessing", error);
195
- }
196
- }
197
- const fileBuffer = await promises.readFile(baseDoc.localPath);
198
- const header = fileBuffer.subarray(0, 5).toString("utf-8");
199
- if (header !== "%PDF-") {
200
- try {
201
- await promises.unlink(baseDoc.localPath);
202
- } catch (unlinkError) {
203
- console.warn(
204
- `Failed to delete poisoned cache file: ${baseDoc.localPath}`,
205
- unlinkError
206
- );
207
- }
208
- const content = fileBuffer.toString(
209
- "utf-8",
210
- 0,
211
- Math.min(1e3, fileBuffer.length)
212
- );
213
- if (content.includes("<!DOCTYPE html>") || content.includes("<html")) {
214
- throw new Error(
215
- `Downloaded file is HTML, not PDF. The server returned HTML content for ${url}. This commonly occurs with WordPress Download Manager URLs that return tracking pages. Expected PDF magic bytes (%PDF-) but got: ${header}. The poisoned cache file has been removed - please try again.`
216
- );
217
- } else {
218
- throw new Error(
219
- `Downloaded file is not a valid PDF. Expected %PDF- magic bytes but got: ${header}. The invalid cache file has been removed - please try again.`
220
- );
221
- }
222
- }
223
- const reader = await getPDFReader();
224
- const extractedText = await reader.extractText(baseDoc.localPath);
225
- const mainPart = {
226
- id: v4(),
227
- title: getTitleFromUrl(url, "PDF Document"),
228
- content: extractedText || "",
229
- type: "text",
230
- metadata: {
231
- source: "pdf",
232
- filePath: baseDoc.localPath
233
- }
234
- };
235
- if (options.extractImages === true) {
236
- mainPart.images = await this.extractImages(
237
- baseDoc.localPath,
238
- options.runOcr !== false
239
- );
240
- }
241
- const document = {
242
- url: baseDoc.url.toString(),
243
- type: baseDoc.type,
244
- parts: [mainPart],
245
- metadata: {
246
- processor: "pdf",
247
- extractedAt: (/* @__PURE__ */ new Date()).toISOString(),
248
- hasImages: (mainPart.images?.length || 0) > 0
249
- }
250
- };
251
- await setCached(cacheKey, JSON.stringify(document));
252
- return document;
253
- }
254
- /**
255
- * Extract images from PDF
256
- *
257
- * This is a placeholder for future image extraction functionality.
258
- * Will use @happyvertical/pdf's image extraction capabilities when available.
259
- *
260
- * @param filePath - Local PDF file path
261
- * @param runOcr - Whether to run OCR on extracted images
262
- * @returns Promise resolving to array of DocumentImages
263
- */
264
- async extractImages(_filePath, _runOcr) {
265
- return [];
266
- }
172
+ try {
173
+ const filename = new URL(url).pathname.split("/").pop() || defaultTitle;
174
+ return decodeURIComponent(filename).replace(/\.(pdf|html?|md|txt)$/i, "").replace(/[-_]/g, " ").trim();
175
+ } catch {
176
+ return defaultTitle;
177
+ }
267
178
  }
268
- const processors = [new PDFProcessor()];
179
+ //#endregion
180
+ //#region src/processors/pdf.ts
181
+ /**
182
+ * PDF Document Processor
183
+ *
184
+ * Handles PDF documents with support for:
185
+ * - Text extraction from PDF content via `@happyvertical/pdf`
186
+ * - PDF header validation (detects HTML cache poisoning from document management systems)
187
+ * - Processed document caching via `@happyvertical/files`
188
+ *
189
+ * Image extraction and OCR are stubbed for future implementation.
190
+ */
191
+ var PDFProcessor = class {
192
+ /**
193
+ * Check if this processor supports the given MIME type or extension.
194
+ * Accepts `'application/pdf'`, `'.pdf'`, or `'pdf'` (case-insensitive).
195
+ *
196
+ * @param type - MIME type or file extension to check
197
+ * @returns `true` if this processor can handle the given type
198
+ */
199
+ supports(type) {
200
+ return type === "application/pdf" || type.endsWith(".pdf") || type.toLowerCase() === "pdf";
201
+ }
202
+ /**
203
+ * Process a PDF document
204
+ *
205
+ * Extracts text and optionally images/OCR from the PDF, structuring
206
+ * it into hierarchical document parts.
207
+ *
208
+ * @param url - PDF URL or file path
209
+ * @param options - Processing options
210
+ * @returns Promise resolving to structured Document
211
+ */
212
+ async process(url, options = {}) {
213
+ const baseDoc = await Document.create(url, options);
214
+ const cacheKey = `${baseDoc.localPath}.processed_pdf`;
215
+ const cached = await getCached(cacheKey);
216
+ if (cached) try {
217
+ const parsed = JSON.parse(cached);
218
+ return {
219
+ url: baseDoc.url.toString(),
220
+ type: baseDoc.type,
221
+ parts: parsed.parts,
222
+ metadata: parsed.metadata || {}
223
+ };
224
+ } catch (error) {
225
+ console.warn("Cached PDF data corrupted, reprocessing", error);
226
+ }
227
+ const fileBuffer = await promises.readFile(baseDoc.localPath);
228
+ const header = fileBuffer.subarray(0, 5).toString("utf-8");
229
+ if (header !== "%PDF-") {
230
+ try {
231
+ await promises.unlink(baseDoc.localPath);
232
+ } catch (unlinkError) {
233
+ console.warn(`Failed to delete poisoned cache file: ${baseDoc.localPath}`, unlinkError);
234
+ }
235
+ const content = fileBuffer.toString("utf-8", 0, Math.min(1e3, fileBuffer.length));
236
+ if (content.includes("<!DOCTYPE html>") || content.includes("<html")) throw new Error(`Downloaded file is HTML, not PDF. The server returned HTML content for ${url}. This commonly occurs with WordPress Download Manager URLs that return tracking pages. Expected PDF magic bytes (%PDF-) but got: ${header}. The poisoned cache file has been removed - please try again.`);
237
+ else throw new Error(`Downloaded file is not a valid PDF. Expected %PDF- magic bytes but got: ${header}. The invalid cache file has been removed - please try again.`);
238
+ }
239
+ const extractedText = await (await getPDFReader()).extractText(baseDoc.localPath);
240
+ const mainPart = {
241
+ id: v4(),
242
+ title: getTitleFromUrl(url, "PDF Document"),
243
+ content: extractedText || "",
244
+ type: "text",
245
+ metadata: {
246
+ source: "pdf",
247
+ filePath: baseDoc.localPath
248
+ }
249
+ };
250
+ if (options.extractImages === true) mainPart.images = await this.extractImages(baseDoc.localPath, options.runOcr !== false);
251
+ const document = {
252
+ url: baseDoc.url.toString(),
253
+ type: baseDoc.type,
254
+ parts: [mainPart],
255
+ metadata: {
256
+ processor: "pdf",
257
+ extractedAt: (/* @__PURE__ */ new Date()).toISOString(),
258
+ hasImages: (mainPart.images?.length || 0) > 0
259
+ }
260
+ };
261
+ await setCached(cacheKey, JSON.stringify(document));
262
+ return document;
263
+ }
264
+ /**
265
+ * Extract images from PDF
266
+ *
267
+ * This is a placeholder for future image extraction functionality.
268
+ * Will use @happyvertical/pdf's image extraction capabilities when available.
269
+ *
270
+ * @param filePath - Local PDF file path
271
+ * @param runOcr - Whether to run OCR on extracted images
272
+ * @returns Promise resolving to array of DocumentImages
273
+ */
274
+ async extractImages(_filePath, _runOcr) {
275
+ return [];
276
+ }
277
+ };
278
+ //#endregion
279
+ //#region src/factory.ts
280
+ /**
281
+ * Available document processors
282
+ */
283
+ var processors = [new PDFProcessor()];
284
+ /**
285
+ * Fetch a document from a URL with automatic format detection
286
+ *
287
+ * This factory function:
288
+ * 1. Detects the document format (PDF, HTML, Markdown, etc.)
289
+ * 2. Selects the appropriate processor
290
+ * 3. Processes the document into structured parts
291
+ * 4. Returns a Document object with hierarchical content
292
+ *
293
+ * @param url - Document URL or file path (file://, http://, https://)
294
+ * @param options - Fetch and processing options
295
+ * @returns Promise resolving to structured Document
296
+ *
297
+ * @example
298
+ * ```typescript
299
+ * // Fetch a PDF with image extraction and OCR
300
+ * const doc = await fetchDocument('https://example.com/report.pdf', {
301
+ * extractImages: true,
302
+ * runOcr: true
303
+ * });
304
+ *
305
+ * // Access document parts
306
+ * for (const part of doc.parts) {
307
+ * console.log(part.title);
308
+ * console.log(part.content);
309
+ *
310
+ * // Check for images
311
+ * if (part.images) {
312
+ * for (const image of part.images) {
313
+ * console.log(image.url);
314
+ * console.log(image.ocrText); // Text extracted via OCR
315
+ * }
316
+ * }
317
+ * }
318
+ * ```
319
+ */
269
320
  async function fetchDocument(url, options = {}) {
270
- const isWebUrl = url.startsWith("http://") || url.startsWith("https://");
271
- if (isWebUrl && !options.type) {
272
- try {
273
- const scraper = options.scraper === "crawlee" ? "basic" : options.scraper ?? "basic";
274
- const spider = options.spider ?? options.spiderAdapter ?? (options.scraper === "crawlee" ? "crawlee" : "dom");
275
- const scraped = await scrapeDocument(url, {
276
- scraper,
277
- spider,
278
- cache: options.cache,
279
- cacheExpiry: options.cacheExpiry,
280
- headers: options.headers,
281
- timeout: options.timeout,
282
- maxDuration: options.maxDuration,
283
- maxInteractions: options.maxInteractions
284
- });
285
- const hasDocLink = scraped.metadata.strategy === "wordpress-pdf-link" || scraped.metadata.strategy === "civicweb-pdf-link" || scraped.metadata.strategy === "docushare-pdf-link";
286
- if (hasDocLink && scraped.metadata.isPdf && !scraped.metadata.complete) {
287
- url = scraped.url;
288
- options.type = "application/pdf";
289
- }
290
- } catch (error) {
291
- console.warn(
292
- `Spider detection failed for ${url}, falling back to direct download:`,
293
- error
294
- );
295
- }
296
- }
297
- let type = options.type ?? "";
298
- if (!type) {
299
- const urlLower = url.toLowerCase();
300
- if (urlLower.endsWith(".pdf") || urlLower.includes(".pdf?") || urlLower.includes(".pdf#")) {
301
- type = "application/pdf";
302
- } else {
303
- type = getMimeType(url) ?? "";
304
- }
305
- }
306
- const processor = processors.find((p) => p.supports(type));
307
- if (!processor) {
308
- throw new Error(
309
- `No processor available for document type: ${type}. Supported types: PDF (.pdf, application/pdf)`
310
- );
311
- }
312
- return processor.process(url, options);
321
+ if ((url.startsWith("http://") || url.startsWith("https://")) && !options.type) try {
322
+ const scraper = options.scraper === "crawlee" ? "basic" : options.scraper ?? "basic";
323
+ const spider = options.spider ?? options.spiderAdapter ?? (options.scraper === "crawlee" ? "crawlee" : "dom");
324
+ const scraped = await scrapeDocument(url, {
325
+ scraper,
326
+ spider,
327
+ cache: options.cache,
328
+ cacheExpiry: options.cacheExpiry,
329
+ headers: options.headers,
330
+ timeout: options.timeout,
331
+ maxDuration: options.maxDuration,
332
+ maxInteractions: options.maxInteractions
333
+ });
334
+ if ((scraped.metadata.strategy === "wordpress-pdf-link" || scraped.metadata.strategy === "civicweb-pdf-link" || scraped.metadata.strategy === "docushare-pdf-link") && scraped.metadata.isPdf && !scraped.metadata.complete) {
335
+ url = scraped.url;
336
+ options.type = "application/pdf";
337
+ }
338
+ } catch (error) {
339
+ console.warn(`Spider detection failed for ${url}, falling back to direct download:`, error);
340
+ }
341
+ let type = options.type ?? "";
342
+ if (!type) {
343
+ const urlLower = url.toLowerCase();
344
+ if (urlLower.endsWith(".pdf") || urlLower.includes(".pdf?") || urlLower.includes(".pdf#")) type = "application/pdf";
345
+ else type = getMimeType(url) ?? "";
346
+ }
347
+ const processor = processors.find((p) => p.supports(type));
348
+ if (!processor) throw new Error(`No processor available for document type: ${type}. Supported types: PDF (.pdf, application/pdf)`);
349
+ return processor.process(url, options);
313
350
  }
314
- const PACKAGE_VERSION_INITIALIZED = true;
315
- export {
316
- Document,
317
- PACKAGE_VERSION_INITIALIZED,
318
- PDFProcessor,
319
- fetchDocument,
320
- getTitleFromUrl
321
- };
322
- //# sourceMappingURL=index.js.map
351
+ //#endregion
352
+ //#region src/index.ts
353
+ /** @internal */
354
+ var PACKAGE_VERSION_INITIALIZED = true;
355
+ //#endregion
356
+ export { Document, PACKAGE_VERSION_INITIALIZED, PDFProcessor, fetchDocument, getTitleFromUrl };
357
+
358
+ //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":["../src/document.ts","../src/utils.ts","../src/processors/pdf.ts","../src/factory.ts","../src/index.ts"],"sourcesContent":["import os from 'node:os';\nimport path from 'node:path';\nimport { URL } from 'node:url';\nimport { downloadFileWithCache, getMimeType } from '@happyvertical/files';\nimport { makeSlug } from '@happyvertical/utils';\nimport type {\n DocumentPart,\n Document as DocumentType,\n FetchDocumentOptions,\n} from './types';\n\n/**\n * Base document handler with multi-part support\n *\n * Provides functionality for downloading, caching, and structuring documents\n * into hierarchical parts. Specific format processing (PDF, HTML, Markdown)\n * is handled by specialized processors.\n */\nexport class Document {\n /**\n * Flag indicating if document is from a remote source\n */\n protected isRemote = false;\n\n /**\n * Configuration options\n */\n protected options: FetchDocumentOptions;\n\n /**\n * Local file path where document is stored\n */\n private _localPath = '';\n\n /**\n * Directory used for caching files\n */\n private _cacheDir = '';\n\n /**\n * Document URL\n */\n public url: URL;\n\n /**\n * Document MIME type\n */\n public type: string;\n\n /**\n * Document parts (hierarchical structure)\n */\n public parts: DocumentPart[] = [];\n\n /**\n * Document-level metadata\n */\n public metadata: Record<string, any> = {};\n\n /**\n * Get the local file path where document is stored\n */\n public get localPath(): string {\n return this._localPath;\n }\n\n /**\n * Get the directory used for caching files\n */\n public get cacheDir(): string {\n return this._cacheDir;\n }\n\n /**\n * Creates a new Document instance\n *\n * @param url - Document URL or file path\n * @param options - Document configuration options\n */\n constructor(url: string, options: FetchDocumentOptions = {}) {\n this.url = new URL(url);\n this.options = options;\n this.type =\n options.type || getMimeType(this.url.toString()) || 'text/plain';\n\n this._cacheDir =\n options.cacheDir ||\n path.resolve(os.tmpdir(), '.cache', 'have-sdk', 'documents');\n\n if (this.url.protocol.startsWith('file')) {\n // Decode URL-encoded characters in the pathname only (e.g., %20 -> space).\n // Note: Query parameters and hash fragments are not decoded here.\n this._localPath = decodeURIComponent(this.url.pathname);\n this.isRemote = false;\n } else if (this.url.protocol.startsWith('http')) {\n // Generate cache path from URL pathname\n // Query parameters (?) and fragments (#) are automatically excluded from url.pathname\n let pathname = this.url.pathname;\n\n // Remove trailing slash (directory-style URLs)\n if (pathname.endsWith('/')) {\n pathname = pathname.slice(0, -1);\n }\n\n // Add file extension if missing and we know the type\n // This is crucial for URLs like /download/file/?wpdmdl=123 which have no extension\n if (!pathname.match(/\\.[a-z0-9]+$/i)) {\n // Add appropriate extension based on MIME type\n if (\n this.type === 'application/pdf' ||\n options.type === 'application/pdf'\n ) {\n pathname += '.pdf';\n }\n // Future: Add other common extensions (html, json, etc.)\n }\n\n this._localPath = path.join(\n this._cacheDir,\n makeSlug(this.url.hostname),\n pathname,\n );\n this.isRemote = true;\n }\n }\n\n /**\n * Creates and initializes a Document instance\n *\n * Downloads remote files and prepares the document for processing.\n *\n * @param url - Document URL or file path\n * @param options - Document configuration options\n * @returns Promise resolving to the initialized Document\n */\n static async create(\n url: string,\n options: FetchDocumentOptions = {},\n ): Promise<Document> {\n const document = new Document(url, options);\n await document.initialize();\n return document;\n }\n\n /**\n * Initializes the document, downloading it if it's remote\n *\n * @returns Promise that resolves when initialization is complete\n */\n async initialize(): Promise<void> {\n if (this.isRemote) {\n if (!this.url) {\n throw new Error('Cannot initialize remote document: URL is required');\n }\n await downloadFileWithCache(this.url.toString(), this._localPath);\n }\n }\n\n /**\n * Checks if the document is a text-based file that can be read directly\n *\n * @returns Boolean indicating if the file is text-based\n */\n public isTextFile(): boolean {\n if (!this.type) return false;\n\n return (\n this.type.startsWith('text/') ||\n this.type === 'application/json' ||\n this.type === 'application/xml' ||\n this.type === 'application/javascript' ||\n this.type === 'application/typescript' ||\n [\n '.txt',\n '.md',\n '.json',\n '.xml',\n '.html',\n '.css',\n '.js',\n '.ts',\n '.yaml',\n '.yml',\n ].some((ext) => this.localPath.toLowerCase().endsWith(ext))\n );\n }\n\n /**\n * Converts the document to the standard Document interface\n *\n * @returns Document object with URL, type, parts, and metadata\n */\n public toDocument(): DocumentType {\n return {\n url: this.url.toString(),\n type: this.type,\n parts: this.parts,\n metadata: this.metadata,\n };\n }\n}\n\nexport default Document;\n","/**\n * Utility functions for document processing\n */\n\n/**\n * Extract a human-readable title from a URL\n *\n * Takes a URL and extracts the filename from the pathname, then formats it\n * into a readable title by removing the extension and converting separators\n * to spaces. Also decodes URL-encoded characters like %20.\n *\n * @param url - URL string to extract title from\n * @param defaultTitle - Default title to use if extraction fails\n * @returns Formatted title string\n *\n * @example\n * ```typescript\n * getTitleFromUrl('file:///path/to/My%20Document.pdf')\n * // Returns: 'My Document'\n *\n * getTitleFromUrl('https://example.com/research_paper.pdf')\n * // Returns: 'research paper'\n * ```\n */\nexport function getTitleFromUrl(\n url: string,\n defaultTitle = 'Document',\n): string {\n try {\n const urlObj = new URL(url);\n const pathname = urlObj.pathname;\n const filename = pathname.split('/').pop() || defaultTitle;\n\n // Decode URL-encoded characters (e.g., %20 -> space)\n const decodedFilename = decodeURIComponent(filename);\n\n // Remove extension and convert separators to spaces\n return decodedFilename\n .replace(/\\.(pdf|html?|md|txt)$/i, '')\n .replace(/[-_]/g, ' ')\n .trim();\n } catch {\n return defaultTitle;\n }\n}\n","import { promises as fs } from 'node:fs';\nimport { getCached, setCached } from '@happyvertical/files';\nimport { getPDFReader } from '@happyvertical/pdf';\nimport { v4 as uuidv4 } from 'uuid';\nimport { Document as BaseDocument } from '../document';\nimport type {\n Document,\n DocumentImage,\n DocumentPart,\n DocumentProcessor,\n FetchDocumentOptions,\n} from '../types';\nimport { getTitleFromUrl } from '../utils';\n\n/**\n * PDF Document Processor\n *\n * Handles PDF documents with support for:\n * - Text extraction from PDF content via `@happyvertical/pdf`\n * - PDF header validation (detects HTML cache poisoning from document management systems)\n * - Processed document caching via `@happyvertical/files`\n *\n * Image extraction and OCR are stubbed for future implementation.\n */\nexport class PDFProcessor implements DocumentProcessor {\n /**\n * Check if this processor supports the given MIME type or extension.\n * Accepts `'application/pdf'`, `'.pdf'`, or `'pdf'` (case-insensitive).\n *\n * @param type - MIME type or file extension to check\n * @returns `true` if this processor can handle the given type\n */\n supports(type: string): boolean {\n return (\n type === 'application/pdf' ||\n type.endsWith('.pdf') ||\n type.toLowerCase() === 'pdf'\n );\n }\n\n /**\n * Process a PDF document\n *\n * Extracts text and optionally images/OCR from the PDF, structuring\n * it into hierarchical document parts.\n *\n * @param url - PDF URL or file path\n * @param options - Processing options\n * @returns Promise resolving to structured Document\n */\n async process(\n url: string,\n options: FetchDocumentOptions = {},\n ): Promise<Document> {\n // Create and initialize base document\n const baseDoc = await BaseDocument.create(url, options);\n\n // Check cache for processed document\n const cacheKey = `${baseDoc.localPath}.processed_pdf`;\n const cached = await getCached(cacheKey);\n if (cached) {\n try {\n const parsed = JSON.parse(cached);\n return {\n url: baseDoc.url.toString(),\n type: baseDoc.type,\n parts: parsed.parts,\n metadata: parsed.metadata || {},\n };\n } catch (error) {\n // Cache corrupted, continue with fresh processing\n console.warn('Cached PDF data corrupted, reprocessing', error);\n }\n }\n\n // Validate that the downloaded file is actually a PDF (issue #460, #463)\n // WordPress Download Manager and some other servers may return HTML\n // with Content-Type: application/pdf, causing PDF extraction to fail\n const fileBuffer = await fs.readFile(baseDoc.localPath);\n const header = fileBuffer.subarray(0, 5).toString('utf-8');\n\n if (header !== '%PDF-') {\n // File is not a valid PDF - delete poisoned cache file (issue #463)\n try {\n await fs.unlink(baseDoc.localPath);\n } catch (unlinkError) {\n console.warn(\n `Failed to delete poisoned cache file: ${baseDoc.localPath}`,\n unlinkError,\n );\n }\n\n // Check if it's HTML to provide helpful error message\n const content = fileBuffer.toString(\n 'utf-8',\n 0,\n Math.min(1000, fileBuffer.length),\n );\n if (content.includes('<!DOCTYPE html>') || content.includes('<html')) {\n throw new Error(\n `Downloaded file is HTML, not PDF. The server returned HTML content for ${url}. ` +\n 'This commonly occurs with WordPress Download Manager URLs that return tracking pages. ' +\n `Expected PDF magic bytes (%PDF-) but got: ${header}. ` +\n 'The poisoned cache file has been removed - please try again.',\n );\n } else {\n throw new Error(\n `Downloaded file is not a valid PDF. Expected %PDF- magic bytes but got: ${header}. ` +\n 'The invalid cache file has been removed - please try again.',\n );\n }\n }\n\n // Get PDF reader and extract content\n const reader = await getPDFReader();\n const extractedText = await reader.extractText(baseDoc.localPath);\n\n // Create main document part\n const mainPart: DocumentPart = {\n id: uuidv4(),\n title: getTitleFromUrl(url, 'PDF Document'),\n content: extractedText || '',\n type: 'text',\n metadata: {\n source: 'pdf',\n filePath: baseDoc.localPath,\n },\n };\n\n // Extract images if enabled\n if (options.extractImages === true) {\n mainPart.images = await this.extractImages(\n baseDoc.localPath,\n options.runOcr !== false,\n );\n }\n\n const document: Document = {\n url: baseDoc.url.toString(),\n type: baseDoc.type,\n parts: [mainPart],\n metadata: {\n processor: 'pdf',\n extractedAt: new Date().toISOString(),\n hasImages: (mainPart.images?.length || 0) > 0,\n },\n };\n\n // Cache the processed document\n await setCached(cacheKey, JSON.stringify(document));\n\n return document;\n }\n\n /**\n * Extract images from PDF\n *\n * This is a placeholder for future image extraction functionality.\n * Will use @happyvertical/pdf's image extraction capabilities when available.\n *\n * @param filePath - Local PDF file path\n * @param runOcr - Whether to run OCR on extracted images\n * @returns Promise resolving to array of DocumentImages\n */\n private async extractImages(\n _filePath: string,\n _runOcr: boolean,\n ): Promise<DocumentImage[]> {\n // TODO: Implement image extraction using @happyvertical/pdf\n // For now, return empty array as placeholder\n\n // Future implementation will:\n // 1. Use getPDFReader() to extract images from PDF\n // 2. Save images to cache directory\n // 3. If runOcr is true, use @happyvertical/ocr to extract text from images\n // 4. Return array of DocumentImage objects with metadata\n\n return [];\n }\n}\n\nexport default PDFProcessor;\n","import { getMimeType } from '@happyvertical/files';\nimport { scrapeDocument } from '@happyvertical/spider';\nimport { PDFProcessor } from './processors/pdf';\nimport type { Document, FetchDocumentOptions } from './types';\n\n/**\n * Available document processors\n */\nconst processors = [new PDFProcessor()];\n\n/**\n * Fetch a document from a URL with automatic format detection\n *\n * This factory function:\n * 1. Detects the document format (PDF, HTML, Markdown, etc.)\n * 2. Selects the appropriate processor\n * 3. Processes the document into structured parts\n * 4. Returns a Document object with hierarchical content\n *\n * @param url - Document URL or file path (file://, http://, https://)\n * @param options - Fetch and processing options\n * @returns Promise resolving to structured Document\n *\n * @example\n * ```typescript\n * // Fetch a PDF with image extraction and OCR\n * const doc = await fetchDocument('https://example.com/report.pdf', {\n * extractImages: true,\n * runOcr: true\n * });\n *\n * // Access document parts\n * for (const part of doc.parts) {\n * console.log(part.title);\n * console.log(part.content);\n *\n * // Check for images\n * if (part.images) {\n * for (const image of part.images) {\n * console.log(image.url);\n * console.log(image.ocrText); // Text extracted via OCR\n * }\n * }\n * }\n * ```\n */\nexport async function fetchDocument(\n url: string,\n options: FetchDocumentOptions = {},\n): Promise<Document> {\n // For web URLs (http/https), use spider package to detect special cases\n // (WordPress Download Manager, CivicWeb, DocuShare, etc.)\n const isWebUrl = url.startsWith('http://') || url.startsWith('https://');\n\n if (isWebUrl && !options.type) {\n try {\n const scraper =\n options.scraper === 'crawlee' ? 'basic' : (options.scraper ?? 'basic');\n const spider =\n options.spider ??\n options.spiderAdapter ??\n (options.scraper === 'crawlee' ? 'crawlee' : 'dom');\n\n // Use spider to detect WordPress, CivicWeb, DocuShare, and other document management systems\n const scraped = await scrapeDocument(url, {\n scraper,\n spider,\n cache: options.cache,\n cacheExpiry: options.cacheExpiry,\n headers: options.headers,\n timeout: options.timeout,\n maxDuration: options.maxDuration,\n maxInteractions: options.maxInteractions,\n });\n\n // Check if spider detected a document management system with PDF link\n const hasDocLink =\n scraped.metadata.strategy === 'wordpress-pdf-link' ||\n scraped.metadata.strategy === 'civicweb-pdf-link' ||\n scraped.metadata.strategy === 'docushare-pdf-link';\n\n if (hasDocLink && scraped.metadata.isPdf && !scraped.metadata.complete) {\n // Spider detected a document management page and extracted the PDF URL\n // Use the extracted URL for PDF processing\n url = scraped.url;\n options.type = 'application/pdf';\n }\n } catch (error) {\n // If spider fails, continue with direct download\n // This ensures backward compatibility\n console.warn(\n `Spider detection failed for ${url}, falling back to direct download:`,\n error,\n );\n }\n }\n\n // Determine type - check URL extension first, then MIME type\n // This handles servers that return incorrect Content-Type headers (e.g., application/octet-stream for PDFs)\n let type = options.type ?? '';\n\n if (!type) {\n // Extract file extension from URL\n const urlLower = url.toLowerCase();\n\n // Check for common document extensions in URL\n if (\n urlLower.endsWith('.pdf') ||\n urlLower.includes('.pdf?') ||\n urlLower.includes('.pdf#')\n ) {\n type = 'application/pdf';\n } else {\n // Fall back to MIME type detection\n type = getMimeType(url) ?? '';\n }\n }\n\n // Find appropriate processor\n const processor = processors.find((p) => p.supports(type));\n\n if (!processor) {\n throw new Error(\n `No processor available for document type: ${type}. Supported types: PDF (.pdf, application/pdf)`,\n );\n }\n\n // Process document\n return processor.process(url, options);\n}\n\nexport default fetchDocument;\n","/**\n * @happyvertical/documents - Document processing with multi-part structure\n *\n * Provides document processing for PDFs with support for:\n * - Hierarchical document parts\n * - Automatic format detection from URL or MIME type\n * - Document management system detection (WordPress, CivicWeb, DocuShare)\n * - File caching for performance\n *\n * @example\n * ```typescript\n * import { fetchDocument } from '@happyvertical/documents';\n *\n * const doc = await fetchDocument('https://example.com/report.pdf');\n *\n * for (const part of doc.parts) {\n * console.log(part.title);\n * console.log(part.content);\n * }\n * ```\n */\n\n// Base classes\nexport { Document } from './document';\n// Main factory function\nexport { fetchDocument } from './factory';\n\n// Processors\nexport { PDFProcessor } from './processors/pdf';\n// Types\nexport type {\n Document as DocumentType,\n DocumentImage,\n DocumentPart,\n DocumentProcessor,\n FetchDocumentOptions,\n} from './types';\n// Utilities\nexport { getTitleFromUrl } from './utils';\n\n/** @internal */\nexport const PACKAGE_VERSION_INITIALIZED = true;\n"],"names":["URL","BaseDocument","fs","uuidv4"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/document.ts","../src/utils.ts","../src/processors/pdf.ts","../src/factory.ts","../src/index.ts"],"sourcesContent":["import os from 'node:os';\nimport path from 'node:path';\nimport { URL } from 'node:url';\nimport { downloadFileWithCache, getMimeType } from '@happyvertical/files';\nimport { makeSlug } from '@happyvertical/utils';\nimport type {\n DocumentPart,\n Document as DocumentType,\n FetchDocumentOptions,\n} from './types';\n\n/**\n * Base document handler with multi-part support\n *\n * Provides functionality for downloading, caching, and structuring documents\n * into hierarchical parts. Specific format processing (PDF, HTML, Markdown)\n * is handled by specialized processors.\n */\nexport class Document {\n /**\n * Flag indicating if document is from a remote source\n */\n protected isRemote = false;\n\n /**\n * Configuration options\n */\n protected options: FetchDocumentOptions;\n\n /**\n * Local file path where document is stored\n */\n private _localPath = '';\n\n /**\n * Directory used for caching files\n */\n private _cacheDir = '';\n\n /**\n * Document URL\n */\n public url: URL;\n\n /**\n * Document MIME type\n */\n public type: string;\n\n /**\n * Document parts (hierarchical structure)\n */\n public parts: DocumentPart[] = [];\n\n /**\n * Document-level metadata\n */\n public metadata: Record<string, any> = {};\n\n /**\n * Get the local file path where document is stored\n */\n public get localPath(): string {\n return this._localPath;\n }\n\n /**\n * Get the directory used for caching files\n */\n public get cacheDir(): string {\n return this._cacheDir;\n }\n\n /**\n * Creates a new Document instance\n *\n * @param url - Document URL or file path\n * @param options - Document configuration options\n */\n constructor(url: string, options: FetchDocumentOptions = {}) {\n this.url = new URL(url);\n this.options = options;\n this.type =\n options.type || getMimeType(this.url.toString()) || 'text/plain';\n\n this._cacheDir =\n options.cacheDir ||\n path.resolve(os.tmpdir(), '.cache', 'have-sdk', 'documents');\n\n if (this.url.protocol.startsWith('file')) {\n // Decode URL-encoded characters in the pathname only (e.g., %20 -> space).\n // Note: Query parameters and hash fragments are not decoded here.\n this._localPath = decodeURIComponent(this.url.pathname);\n this.isRemote = false;\n } else if (this.url.protocol.startsWith('http')) {\n // Generate cache path from URL pathname\n // Query parameters (?) and fragments (#) are automatically excluded from url.pathname\n let pathname = this.url.pathname;\n\n // Remove trailing slash (directory-style URLs)\n if (pathname.endsWith('/')) {\n pathname = pathname.slice(0, -1);\n }\n\n // Add file extension if missing and we know the type\n // This is crucial for URLs like /download/file/?wpdmdl=123 which have no extension\n if (!pathname.match(/\\.[a-z0-9]+$/i)) {\n // Add appropriate extension based on MIME type\n if (\n this.type === 'application/pdf' ||\n options.type === 'application/pdf'\n ) {\n pathname += '.pdf';\n }\n // Future: Add other common extensions (html, json, etc.)\n }\n\n this._localPath = path.join(\n this._cacheDir,\n makeSlug(this.url.hostname),\n pathname,\n );\n this.isRemote = true;\n }\n }\n\n /**\n * Creates and initializes a Document instance\n *\n * Downloads remote files and prepares the document for processing.\n *\n * @param url - Document URL or file path\n * @param options - Document configuration options\n * @returns Promise resolving to the initialized Document\n */\n static async create(\n url: string,\n options: FetchDocumentOptions = {},\n ): Promise<Document> {\n const document = new Document(url, options);\n await document.initialize();\n return document;\n }\n\n /**\n * Initializes the document, downloading it if it's remote\n *\n * @returns Promise that resolves when initialization is complete\n */\n async initialize(): Promise<void> {\n if (this.isRemote) {\n if (!this.url) {\n throw new Error('Cannot initialize remote document: URL is required');\n }\n await downloadFileWithCache(this.url.toString(), this._localPath);\n }\n }\n\n /**\n * Checks if the document is a text-based file that can be read directly\n *\n * @returns Boolean indicating if the file is text-based\n */\n public isTextFile(): boolean {\n if (!this.type) return false;\n\n return (\n this.type.startsWith('text/') ||\n this.type === 'application/json' ||\n this.type === 'application/xml' ||\n this.type === 'application/javascript' ||\n this.type === 'application/typescript' ||\n [\n '.txt',\n '.md',\n '.json',\n '.xml',\n '.html',\n '.css',\n '.js',\n '.ts',\n '.yaml',\n '.yml',\n ].some((ext) => this.localPath.toLowerCase().endsWith(ext))\n );\n }\n\n /**\n * Converts the document to the standard Document interface\n *\n * @returns Document object with URL, type, parts, and metadata\n */\n public toDocument(): DocumentType {\n return {\n url: this.url.toString(),\n type: this.type,\n parts: this.parts,\n metadata: this.metadata,\n };\n }\n}\n\nexport default Document;\n","/**\n * Utility functions for document processing\n */\n\n/**\n * Extract a human-readable title from a URL\n *\n * Takes a URL and extracts the filename from the pathname, then formats it\n * into a readable title by removing the extension and converting separators\n * to spaces. Also decodes URL-encoded characters like %20.\n *\n * @param url - URL string to extract title from\n * @param defaultTitle - Default title to use if extraction fails\n * @returns Formatted title string\n *\n * @example\n * ```typescript\n * getTitleFromUrl('file:///path/to/My%20Document.pdf')\n * // Returns: 'My Document'\n *\n * getTitleFromUrl('https://example.com/research_paper.pdf')\n * // Returns: 'research paper'\n * ```\n */\nexport function getTitleFromUrl(\n url: string,\n defaultTitle = 'Document',\n): string {\n try {\n const urlObj = new URL(url);\n const pathname = urlObj.pathname;\n const filename = pathname.split('/').pop() || defaultTitle;\n\n // Decode URL-encoded characters (e.g., %20 -> space)\n const decodedFilename = decodeURIComponent(filename);\n\n // Remove extension and convert separators to spaces\n return decodedFilename\n .replace(/\\.(pdf|html?|md|txt)$/i, '')\n .replace(/[-_]/g, ' ')\n .trim();\n } catch {\n return defaultTitle;\n }\n}\n","import { promises as fs } from 'node:fs';\nimport { getCached, setCached } from '@happyvertical/files';\nimport { getPDFReader } from '@happyvertical/pdf';\nimport { v4 as uuidv4 } from 'uuid';\nimport { Document as BaseDocument } from '../document';\nimport type {\n Document,\n DocumentImage,\n DocumentPart,\n DocumentProcessor,\n FetchDocumentOptions,\n} from '../types';\nimport { getTitleFromUrl } from '../utils';\n\n/**\n * PDF Document Processor\n *\n * Handles PDF documents with support for:\n * - Text extraction from PDF content via `@happyvertical/pdf`\n * - PDF header validation (detects HTML cache poisoning from document management systems)\n * - Processed document caching via `@happyvertical/files`\n *\n * Image extraction and OCR are stubbed for future implementation.\n */\nexport class PDFProcessor implements DocumentProcessor {\n /**\n * Check if this processor supports the given MIME type or extension.\n * Accepts `'application/pdf'`, `'.pdf'`, or `'pdf'` (case-insensitive).\n *\n * @param type - MIME type or file extension to check\n * @returns `true` if this processor can handle the given type\n */\n supports(type: string): boolean {\n return (\n type === 'application/pdf' ||\n type.endsWith('.pdf') ||\n type.toLowerCase() === 'pdf'\n );\n }\n\n /**\n * Process a PDF document\n *\n * Extracts text and optionally images/OCR from the PDF, structuring\n * it into hierarchical document parts.\n *\n * @param url - PDF URL or file path\n * @param options - Processing options\n * @returns Promise resolving to structured Document\n */\n async process(\n url: string,\n options: FetchDocumentOptions = {},\n ): Promise<Document> {\n // Create and initialize base document\n const baseDoc = await BaseDocument.create(url, options);\n\n // Check cache for processed document\n const cacheKey = `${baseDoc.localPath}.processed_pdf`;\n const cached = await getCached(cacheKey);\n if (cached) {\n try {\n const parsed = JSON.parse(cached);\n return {\n url: baseDoc.url.toString(),\n type: baseDoc.type,\n parts: parsed.parts,\n metadata: parsed.metadata || {},\n };\n } catch (error) {\n // Cache corrupted, continue with fresh processing\n console.warn('Cached PDF data corrupted, reprocessing', error);\n }\n }\n\n // Validate that the downloaded file is actually a PDF (issue #460, #463)\n // WordPress Download Manager and some other servers may return HTML\n // with Content-Type: application/pdf, causing PDF extraction to fail\n const fileBuffer = await fs.readFile(baseDoc.localPath);\n const header = fileBuffer.subarray(0, 5).toString('utf-8');\n\n if (header !== '%PDF-') {\n // File is not a valid PDF - delete poisoned cache file (issue #463)\n try {\n await fs.unlink(baseDoc.localPath);\n } catch (unlinkError) {\n console.warn(\n `Failed to delete poisoned cache file: ${baseDoc.localPath}`,\n unlinkError,\n );\n }\n\n // Check if it's HTML to provide helpful error message\n const content = fileBuffer.toString(\n 'utf-8',\n 0,\n Math.min(1000, fileBuffer.length),\n );\n if (content.includes('<!DOCTYPE html>') || content.includes('<html')) {\n throw new Error(\n `Downloaded file is HTML, not PDF. The server returned HTML content for ${url}. ` +\n 'This commonly occurs with WordPress Download Manager URLs that return tracking pages. ' +\n `Expected PDF magic bytes (%PDF-) but got: ${header}. ` +\n 'The poisoned cache file has been removed - please try again.',\n );\n } else {\n throw new Error(\n `Downloaded file is not a valid PDF. Expected %PDF- magic bytes but got: ${header}. ` +\n 'The invalid cache file has been removed - please try again.',\n );\n }\n }\n\n // Get PDF reader and extract content\n const reader = await getPDFReader();\n const extractedText = await reader.extractText(baseDoc.localPath);\n\n // Create main document part\n const mainPart: DocumentPart = {\n id: uuidv4(),\n title: getTitleFromUrl(url, 'PDF Document'),\n content: extractedText || '',\n type: 'text',\n metadata: {\n source: 'pdf',\n filePath: baseDoc.localPath,\n },\n };\n\n // Extract images if enabled\n if (options.extractImages === true) {\n mainPart.images = await this.extractImages(\n baseDoc.localPath,\n options.runOcr !== false,\n );\n }\n\n const document: Document = {\n url: baseDoc.url.toString(),\n type: baseDoc.type,\n parts: [mainPart],\n metadata: {\n processor: 'pdf',\n extractedAt: new Date().toISOString(),\n hasImages: (mainPart.images?.length || 0) > 0,\n },\n };\n\n // Cache the processed document\n await setCached(cacheKey, JSON.stringify(document));\n\n return document;\n }\n\n /**\n * Extract images from PDF\n *\n * This is a placeholder for future image extraction functionality.\n * Will use @happyvertical/pdf's image extraction capabilities when available.\n *\n * @param filePath - Local PDF file path\n * @param runOcr - Whether to run OCR on extracted images\n * @returns Promise resolving to array of DocumentImages\n */\n private async extractImages(\n _filePath: string,\n _runOcr: boolean,\n ): Promise<DocumentImage[]> {\n // TODO: Implement image extraction using @happyvertical/pdf\n // For now, return empty array as placeholder\n\n // Future implementation will:\n // 1. Use getPDFReader() to extract images from PDF\n // 2. Save images to cache directory\n // 3. If runOcr is true, use @happyvertical/ocr to extract text from images\n // 4. Return array of DocumentImage objects with metadata\n\n return [];\n }\n}\n\nexport default PDFProcessor;\n","import { getMimeType } from '@happyvertical/files';\nimport { scrapeDocument } from '@happyvertical/spider';\nimport { PDFProcessor } from './processors/pdf';\nimport type { Document, FetchDocumentOptions } from './types';\n\n/**\n * Available document processors\n */\nconst processors = [new PDFProcessor()];\n\n/**\n * Fetch a document from a URL with automatic format detection\n *\n * This factory function:\n * 1. Detects the document format (PDF, HTML, Markdown, etc.)\n * 2. Selects the appropriate processor\n * 3. Processes the document into structured parts\n * 4. Returns a Document object with hierarchical content\n *\n * @param url - Document URL or file path (file://, http://, https://)\n * @param options - Fetch and processing options\n * @returns Promise resolving to structured Document\n *\n * @example\n * ```typescript\n * // Fetch a PDF with image extraction and OCR\n * const doc = await fetchDocument('https://example.com/report.pdf', {\n * extractImages: true,\n * runOcr: true\n * });\n *\n * // Access document parts\n * for (const part of doc.parts) {\n * console.log(part.title);\n * console.log(part.content);\n *\n * // Check for images\n * if (part.images) {\n * for (const image of part.images) {\n * console.log(image.url);\n * console.log(image.ocrText); // Text extracted via OCR\n * }\n * }\n * }\n * ```\n */\nexport async function fetchDocument(\n url: string,\n options: FetchDocumentOptions = {},\n): Promise<Document> {\n // For web URLs (http/https), use spider package to detect special cases\n // (WordPress Download Manager, CivicWeb, DocuShare, etc.)\n const isWebUrl = url.startsWith('http://') || url.startsWith('https://');\n\n if (isWebUrl && !options.type) {\n try {\n const scraper =\n options.scraper === 'crawlee' ? 'basic' : (options.scraper ?? 'basic');\n const spider =\n options.spider ??\n options.spiderAdapter ??\n (options.scraper === 'crawlee' ? 'crawlee' : 'dom');\n\n // Use spider to detect WordPress, CivicWeb, DocuShare, and other document management systems\n const scraped = await scrapeDocument(url, {\n scraper,\n spider,\n cache: options.cache,\n cacheExpiry: options.cacheExpiry,\n headers: options.headers,\n timeout: options.timeout,\n maxDuration: options.maxDuration,\n maxInteractions: options.maxInteractions,\n });\n\n // Check if spider detected a document management system with PDF link\n const hasDocLink =\n scraped.metadata.strategy === 'wordpress-pdf-link' ||\n scraped.metadata.strategy === 'civicweb-pdf-link' ||\n scraped.metadata.strategy === 'docushare-pdf-link';\n\n if (hasDocLink && scraped.metadata.isPdf && !scraped.metadata.complete) {\n // Spider detected a document management page and extracted the PDF URL\n // Use the extracted URL for PDF processing\n url = scraped.url;\n options.type = 'application/pdf';\n }\n } catch (error) {\n // If spider fails, continue with direct download\n // This ensures backward compatibility\n console.warn(\n `Spider detection failed for ${url}, falling back to direct download:`,\n error,\n );\n }\n }\n\n // Determine type - check URL extension first, then MIME type\n // This handles servers that return incorrect Content-Type headers (e.g., application/octet-stream for PDFs)\n let type = options.type ?? '';\n\n if (!type) {\n // Extract file extension from URL\n const urlLower = url.toLowerCase();\n\n // Check for common document extensions in URL\n if (\n urlLower.endsWith('.pdf') ||\n urlLower.includes('.pdf?') ||\n urlLower.includes('.pdf#')\n ) {\n type = 'application/pdf';\n } else {\n // Fall back to MIME type detection\n type = getMimeType(url) ?? '';\n }\n }\n\n // Find appropriate processor\n const processor = processors.find((p) => p.supports(type));\n\n if (!processor) {\n throw new Error(\n `No processor available for document type: ${type}. Supported types: PDF (.pdf, application/pdf)`,\n );\n }\n\n // Process document\n return processor.process(url, options);\n}\n\nexport default fetchDocument;\n","/**\n * @happyvertical/documents - Document processing with multi-part structure\n *\n * Provides document processing for PDFs with support for:\n * - Hierarchical document parts\n * - Automatic format detection from URL or MIME type\n * - Document management system detection (WordPress, CivicWeb, DocuShare)\n * - File caching for performance\n *\n * @example\n * ```typescript\n * import { fetchDocument } from '@happyvertical/documents';\n *\n * const doc = await fetchDocument('https://example.com/report.pdf');\n *\n * for (const part of doc.parts) {\n * console.log(part.title);\n * console.log(part.content);\n * }\n * ```\n */\n\n// Base classes\nexport { Document } from './document';\n// Main factory function\nexport { fetchDocument } from './factory';\n\n// Processors\nexport { PDFProcessor } from './processors/pdf';\n// Types\nexport type {\n Document as DocumentType,\n DocumentImage,\n DocumentPart,\n DocumentProcessor,\n FetchDocumentOptions,\n} from './types';\n// Utilities\nexport { getTitleFromUrl } from './utils';\n\n/** @internal */\nexport const PACKAGE_VERSION_INITIALIZED = true;\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/documents",
3
- "version": "0.80.0",
3
+ "version": "0.80.1",
4
4
  "description": "Multi-part document processing with support for PDF, HTML, and Markdown",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -40,14 +40,14 @@
40
40
  "@happyvertical/pdf": "^0.65",
41
41
  "@happyvertical/spider": "^1.1",
42
42
  "uuid": "^13.0.0",
43
- "@happyvertical/files": "0.80.0",
44
- "@happyvertical/utils": "0.80.0"
43
+ "@happyvertical/files": "0.80.1",
44
+ "@happyvertical/utils": "0.80.1"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/node": "25.0.10",
48
- "typescript": "^5.9.3",
49
- "vite": "7.3.2",
50
- "vitest": "^4.1.5"
48
+ "typescript": "5.9.3",
49
+ "vite": "8.1.4",
50
+ "vitest": "4.1.10"
51
51
  },
52
52
  "keywords": [
53
53
  "documents",