Repository: arabold/docs-mcp-server
Pull request #135 (2025-05-24 21:16:18)

feat(local-file-support): improve local file support in server and web interface (#128)
### Summary

This PR improves local file support in both the server and web interface:

- Clarifies and documents that all `text/*` files (including HTML, Markdown, plain text, and source code) are processed by LocalFileStrategy
- Updates the README and LocalFileStrategy docs to reflect actual file type support
- Web UI: Only shows the 'subpages' scope info for non-file URLs

Addresses #128

---
- Updates documentation and user interface for clarity
- No breaking changes
- Ready for review


Patch:
diff --git a/README.md b/README.md
index 3e4b9ac..614de8b 100644
--- a/README.md
+++ b/README.md
@@ -135,6 +135,36 @@ Once the Docs MCP Server is running, you can use the Web Interface to **add new
 
 **That's it!** Once a job completes successfully, the documentation for that library and version becomes available for searching through your connected AI coding assistant (using the `search_docs` tool) or directly in the Web UI by clicking on the library name in the "Indexed Documenation" section.
 
+## Scraping Local Files and Folders
+
+You can index documentation from your local filesystem by using a `file://` URL as the source. This works in both the Web UI and CLI.
+
+**Examples:**
+
+- Web: `https://react.dev/reference/react`
+- Local file: `file:///Users/me/docs/index.html`
+- Local folder: `file:///Users/me/docs/my-library`
+
+**Requirements:**
+
+- All files with a MIME type of `text/*` are processed. This includes HTML, Markdown, plain text, and source code files such as `.js`, `.ts`, `.tsx`, `.css`, etc. Binary files, PDFs, images, and other non-text formats are ignored.
+- You must use the `file://` prefix for local files/folders.
+- The path must be accessible to the server process.
+- **If running in Docker or Docker Compose:**
+  - You must mount the local folder into the container and use the container path in your `file://` URL.
+  - Example Docker run:
+    ```bash
+    docker run --rm \
+      -e OPENAI_API_KEY="your-key" \
+      -v /absolute/path/to/docs:/docs:ro \
+      -v docs-mcp-data:/data \
+      ghcr.io/arabold/docs-mcp-server:latest \
+      scrape mylib file:///docs/my-library
+    ```
+  - In the Web UI, enter the path as `file:///docs/my-library` (matching the container path).
+
+See the tooltips in the Web UI and CLI help for more details.
+
 ## Alternative: Using Docker
 
 > **Note:** The published Docker images now support both x86_64 (amd64) and Mac Silicon (arm64) platforms.
diff --git a/package-lock.json b/package-lock.json
index 4eca589..d8fe8b6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -38,6 +38,7 @@
         "iconv-lite": "^0.6.3",
         "jsdom": "^26.1.0",
         "langchain": "0.3.19",
+        "mime-types": "^3.0.1",
         "minimatch": "^10.0.1",
         "playwright": "^1.52.0",
         "psl": "^1.15.0",
@@ -66,6 +67,7 @@
         "@types/better-sqlite3": "^7.6.13",
         "@types/jsdom": "~21.1.7",
         "@types/lint-staged": "~13.3.0",
+        "@types/mime-types": "^2.1.4",
         "@types/node": "^20.17.47",
         "@types/node-fetch": "^2.6.12",
         "@types/psl": "^1.1.3",
@@ -6241,6 +6243,13 @@
         "@types/unist": "*"
       }
     },
+    "node_modules/@types/mime-types": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz",
+      "integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/ms": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
diff --git a/package.json b/package.json
index e3b4e7a..51a1406 100644
--- a/package.json
+++ b/package.json
@@ -69,6 +69,7 @@
     "iconv-lite": "^0.6.3",
     "jsdom": "^26.1.0",
     "langchain": "0.3.19",
+    "mime-types": "^3.0.1",
     "minimatch": "^10.0.1",
     "playwright": "^1.52.0",
     "psl": "^1.15.0",
@@ -94,6 +95,7 @@
     "@types/better-sqlite3": "^7.6.13",
     "@types/jsdom": "~21.1.7",
     "@types/lint-staged": "~13.3.0",
+    "@types/mime-types": "^2.1.4",
     "@types/node": "^20.17.47",
     "@types/node-fetch": "^2.6.12",
     "@types/psl": "^1.1.3",
diff --git a/src/index.ts b/src/index.ts
index 2abe885..d80e766 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -201,7 +201,15 @@ async function main() {
     // --- Scrape Command ---
     program
       .command("scrape <library> <url>")
-      .description("Scrape and index documentation from a URL")
+      .description(
+        "Scrape and index documentation from a URL or local folder.\n\n" +
+          "To scrape local files or folders, use a file:// URL.\n" +
+          "Examples:\n" +
+          "  scrape mylib https://react.dev/reference/react\n" +
+          "  scrape mylib file:///Users/me/docs/index.html\n" +
+          "  scrape mylib file:///Users/me/docs/my-library\n" +
+          "\nNote: For local files/folders, you must use the file:// prefix. If running in Docker, mount the folder and use the container path. See README for details.",
+      )
       .option("-v, --version <string>", "Version of the library (optional)")
       .option(
         "-p, --max-pages <number>",
diff --git a/src/mcp/index.ts b/src/mcp/index.ts
index f733e1f..baa9bde 100644
--- a/src/mcp/index.ts
+++ b/src/mcp/index.ts
@@ -70,7 +70,7 @@ export async function stopServer() {
   // DocumentManagementService and PipelineManager instances are managed and shut down by src/index.ts.
 
   if (hadError) {
-    logger.warn("⚠️ MCP Server instance stopped with errors.");
+    logger.warn("⚠️  MCP Server instance stopped with errors.");
   } else {
     logger.info("✅ MCP Server instance stopped.");
   }
diff --git a/src/mcp/mcpServer.ts b/src/mcp/mcpServer.ts
index c52bd22..0bac806 100644
--- a/src/mcp/mcpServer.ts
+++ b/src/mcp/mcpServer.ts
@@ -477,7 +477,7 @@ ${r.content}\n`,
           // Handle invalid status - perhaps return an error or ignore?
           // For simplicity, let's ignore invalid status for now and return all jobs.
           // Alternatively, could throw an McpError or return specific error content.
-          logger.warn(`⚠️ Invalid status parameter received: ${statusParam}`);
+          logger.warn(`⚠️  Invalid status parameter received: ${statusParam}`);
         }
       }
 
@@ -515,7 +515,7 @@ ${r.content}\n`,
       // Validate jobId format if necessary (basic check)
       if (typeof jobId !== "string" || jobId.length === 0) {
         // Handle invalid jobId format - return empty or error
-        logger.warn(`⚠️ Invalid jobId received in URI: ${jobId}`);
+        logger.warn(`⚠️  Invalid jobId received in URI: ${jobId}`);
         return { contents: [] }; // Return empty content for invalid ID format
       }
 
diff --git a/src/pipeline/PipelineManager.ts b/src/pipeline/PipelineManager.ts
index e565878..d59bbb4 100644
--- a/src/pipeline/PipelineManager.ts
+++ b/src/pipeline/PipelineManager.ts
@@ -46,7 +46,7 @@ export class PipelineManager {
    */
   async start(): Promise<void> {
     if (this.isRunning) {
-      logger.warn("⚠️ PipelineManager is already running.");
+      logger.warn("⚠️  PipelineManager is already running.");
       return;
     }
     this.isRunning = true;
@@ -61,7 +61,7 @@ export class PipelineManager {
    */
   async stop(): Promise<void> {
     if (!this.isRunning) {
-      logger.warn("⚠️ PipelineManager is not running.");
+      logger.warn("⚠️  PipelineManager is not running.");
       return;
     }
     this.isRunning = false;
@@ -214,7 +214,7 @@ export class PipelineManager {
       case PipelineJobStatus.CANCELLED:
       case PipelineJobStatus.CANCELLING:
         logger.warn(
-          `⚠️ Job ${jobId} cannot be cancelled in its current state: ${job.status}`,
+          `⚠️  Job ${jobId} cannot be cancelled in its current state: ${job.status}`,
         );
         break;
 
diff --git a/src/pipeline/PipelineWorker.ts b/src/pipeline/PipelineWorker.ts
index 191f017..b54df2a 100644
--- a/src/pipeline/PipelineWorker.ts
+++ b/src/pipeline/PipelineWorker.ts
@@ -84,7 +84,7 @@ export class PipelineWorker {
       logger.debug(`[${jobId}] Worker finished job successfully.`);
     } catch (error) {
       // Re-throw error to be caught by the manager in _runJob
-      logger.warn(`⚠️ [${jobId}] Worker encountered error: ${error}`);
+      logger.warn(`⚠️  [${jobId}] Worker encountered error: ${error}`);
       throw error;
     }
     // Note: The manager (_runJob) is responsible for updating final job status (COMPLETED/FAILED/CANCELLED)
diff --git a/src/scraper/fetcher/FileFetcher.ts b/src/scraper/fetcher/FileFetcher.ts
index 76022d4..4f7b319 100644
--- a/src/scraper/fetcher/FileFetcher.ts
+++ b/src/scraper/fetcher/FileFetcher.ts
@@ -1,5 +1,6 @@
 import fs from "node:fs/promises";
 import path from "node:path";
+import * as mime from "mime-types";
 import { ScraperError } from "../../utils/errors";
 import { logger } from "../../utils/logger";
 import type { ContentFetcher, FetchOptions, RawContent } from "./types";
@@ -12,14 +13,19 @@ export class FileFetcher implements ContentFetcher {
     return source.startsWith("file://");
   }
 
+  /**
+   * Fetches the content of a file given a file:// URL, decoding percent-encoded paths as needed.
+   * Only HTML and Markdown files are processed.
+   */
   async fetch(source: string, options?: FetchOptions): Promise<RawContent> {
-    const filePath = source.replace(/^file:\/\//, "");
-    logger.info(`📄 Fetching file: ${filePath}`);
+    // Always decode the file path from file:// URL
+    const rawPath = source.replace("file://", "");
+    const filePath = decodeURIComponent(rawPath);
 
     try {
       const content = await fs.readFile(filePath);
       const ext = path.extname(filePath).toLowerCase();
-      const mimeType = this.getMimeType(ext, content);
+      const mimeType = mime.lookup(ext) || "application/octet-stream";
       return {
         content,
         mimeType,
@@ -36,34 +42,4 @@ export class FileFetcher implements ContentFetcher {
       );
     }
   }
-
-  /**
-   * Returns the MIME type for a file extension, optionally inspecting content for binary detection.
-   * For known text extensions, always returns the text MIME type.
-   * For unknown extensions, checks for null bytes in the first 8000 bytes to detect binary files.
-   */
-  private getMimeType(ext: string, content?: Buffer): string {
-    switch (ext) {
-      case ".html":
-      case ".htm":
-      case ".htmx":
-        return "text/html";
-      case ".md":
-      case ".mdx":
-      case ".markdown":
-        return "text/markdown";
-      case ".txt":
-      case ".text":
-        return "text/plain";
-      default:
-        if (content) {
-          // Fast null byte check using Buffer.indexOf
-          const maxCheck = Math.min(content.length, 8000);
-          if (content.subarray(0, maxCheck).indexOf(0) !== -1) {
-            return "application/octet-stream";
-          }
-        }
-        return "application/octet-stream";
-    }
-  }
 }
diff --git a/src/scraper/fetcher/HttpFetcher.ts b/src/scraper/fetcher/HttpFetcher.ts
index cbe5c04..14b7c93 100644
--- a/src/scraper/fetcher/HttpFetcher.ts
+++ b/src/scraper/fetcher/HttpFetcher.ts
@@ -89,7 +89,7 @@ export class HttpFetcher implements ContentFetcher {
         ) {
           const delay = baseDelay * 2 ** attempt;
           logger.warn(
-            `⚠️ Attempt ${attempt + 1}/${
+            `⚠️  Attempt ${attempt + 1}/${
               maxRetries + 1
             } failed for ${source} (Status: ${status}, Code: ${code}). Retrying in ${delay}ms...`,
           );
diff --git a/src/scraper/middleware/HtmlJsExecutorMiddleware.ts b/src/scraper/middleware/HtmlJsExecutorMiddleware.ts
index 779ca81..f1707c1 100644
--- a/src/scraper/middleware/HtmlJsExecutorMiddleware.ts
+++ b/src/scraper/middleware/HtmlJsExecutorMiddleware.ts
@@ -35,7 +35,7 @@ export class HtmlJsExecutorMiddleware implements ContentProcessorMiddleware {
       ): Promise<string | null> => {
         if (!context.fetcher) {
           logger.warn(
-            `⚠️ No fetcher available in context to fetch external script: ${scriptUrl}`,
+            `⚠️  No fetcher available in context to fetch external script: ${scriptUrl}`,
           );
           return null;
         }
@@ -105,7 +105,7 @@ export class HtmlJsExecutorMiddleware implements ContentProcessorMiddleware {
           // fetcher.fetch is expected to throw on error (e.g., 404, network error)
           const message =
             fetchError instanceof Error ? fetchError.message : String(fetchError);
-          logger.warn(`⚠️ Failed to fetch external script ${scriptUrl}: ${message}`); // Use warn for fetch failures like 404
+          logger.warn(`⚠️  Failed to fetch external script ${scriptUrl}: ${message}`); // Use warn for fetch failures like 404
           context.errors.push(
             new Error(`Failed to fetch external script ${scriptUrl}: ${message}`, {
               cause: fetchError,
@@ -133,7 +133,7 @@ export class HtmlJsExecutorMiddleware implements ContentProcessorMiddleware {
       if (result.errors.length > 0) {
         context.errors.push(...result.errors);
         logger.warn(
-          `⚠️ Encountered ${result.errors.length} error(s) during sandbox execution for ${context.source}`,
+          `⚠️  Encountered ${result.errors.length} error(s) during sandbox execution for ${context.source}`,
         );
       }
 
diff --git a/src/scraper/middleware/HtmlPlaywrightMiddleware.ts b/src/scraper/middleware/HtmlPlaywrightMiddleware.ts
index 7af8bca..98dc73a 100644
--- a/src/scraper/middleware/HtmlPlaywrightMiddleware.ts
+++ b/src/scraper/middleware/HtmlPlaywrightMiddleware.ts
@@ -138,7 +138,7 @@ export class HtmlPlaywrightMiddleware implements ContentProcessorMiddleware {
         );
       }
     } catch (e) {
-      logger.warn(`⚠️ Could not parse URL for credential extraction: ${context.source}`);
+      logger.warn(`⚠️  Could not parse URL for credential extraction: ${context.source}`);
     }
 
     try {
@@ -226,7 +226,7 @@ export class HtmlPlaywrightMiddleware implements ContentProcessorMiddleware {
       );
     } else {
       logger.warn(
-        `⚠️ Playwright rendering resulted in null content for ${context.source}. Proceeding without content update.`,
+        `⚠️  Playwright rendering resulted in null content for ${context.source}. Proceeding without content update.`,
       );
     }
 
diff --git a/src/scraper/middleware/HtmlSanitizerMiddleware.ts b/src/scraper/middleware/HtmlSanitizerMiddleware.ts
index b0adfa5..11278fa 100644
--- a/src/scraper/middleware/HtmlSanitizerMiddleware.ts
+++ b/src/scraper/middleware/HtmlSanitizerMiddleware.ts
@@ -110,7 +110,7 @@ export class HtmlSanitizerMiddleware implements ContentProcessorMiddleware {
           // Log invalid selectors but continue with others
           // Cheerio is generally more tolerant of invalid selectors than querySelectorAll
           logger.warn(
-            `⚠️ Potentially invalid selector "${selector}" during element removal: ${selectorError}`,
+            `⚠️  Potentially invalid selector "${selector}" during element removal: ${selectorError}`,
           );
           context.errors.push(
             new Error(`Invalid selector "${selector}": ${selectorError}`),
diff --git a/src/scraper/middleware/HtmlToMarkdownMiddleware.ts b/src/scraper/middleware/HtmlToMarkdownMiddleware.ts
index e7ab024..1ddb633 100644
--- a/src/scraper/middleware/HtmlToMarkdownMiddleware.ts
+++ b/src/scraper/middleware/HtmlToMarkdownMiddleware.ts
@@ -104,7 +104,7 @@ export class HtmlToMarkdownMiddleware implements ContentProcessorMiddleware {
       if (!markdown) {
         // If conversion results in empty markdown, log a warning but treat as valid empty markdown
         const warnMsg = `HTML to Markdown conversion resulted in empty content for ${context.source}.`;
-        logger.warn(`⚠️ ${warnMsg}`);
+        logger.warn(`⚠️  ${warnMsg}`);
         context.content = "";
       } else {
         // Conversion successful and produced non-empty markdown
diff --git a/src/scraper/strategies/LocalFileStrategy.ts b/src/scraper/strategies/LocalFileStrategy.ts
index 635b240..8621c17 100644
--- a/src/scraper/strategies/LocalFileStrategy.ts
+++ b/src/scraper/strategies/LocalFileStrategy.ts
@@ -9,6 +9,13 @@ import { MarkdownPipeline } from "../pipelines/MarkdownPipeline";
 import type { ScraperOptions, ScraperProgress } from "../types";
 import { BaseScraperStrategy, type QueueItem } from "./BaseScraperStrategy";
 
+/**
+ * LocalFileStrategy handles crawling and scraping of local files and folders using file:// URLs.
+ *
+ * All files with a MIME type of `text/*` are processed. This includes HTML, Markdown, plain text, and source code files such as `.js`, `.ts`, `.tsx`, `.css`, etc. Binary files, PDFs, images, and other non-text formats are ignored.
+ *
+ * Supports include/exclude filters and percent-encoded paths.
+ */
 export class LocalFileStrategy extends BaseScraperStrategy {
   private readonly fileFetcher = new FileFetcher();
   private readonly htmlPipeline: HtmlPipeline;
@@ -32,17 +39,20 @@ export class LocalFileStrategy extends BaseScraperStrategy {
     _progressCallback?: ProgressCallback<ScraperProgress>,
     _signal?: AbortSignal,
   ): Promise<{ document?: Document; links?: string[] }> {
-    const filePath = item.url.replace(/^file:\/\//, "");
+    // Always decode the file path from file:// URL
+    const filePath = decodeURIComponent(item.url.replace(/^file:\/\//, ""));
     const stats = await fs.stat(filePath);
 
     if (stats.isDirectory()) {
       const contents = await fs.readdir(filePath);
-      return {
-        links: contents.map((name) => `file://${path.join(filePath, name)}`),
-      };
+      // Only return links that pass shouldProcessUrl
+      const links = contents
+        .map((name) => `file://${path.join(filePath, name)}`)
+        .filter((url) => this.shouldProcessUrl(url, options));
+      return { links };
     }
 
-    logger.info(`📄 Processing file ${this.pageCount}/${options.maxPages}: ${filePath}`);
+    logger.info(`🗂️  Processing file ${this.pageCount}/${options.maxPages}: ${filePath}`);
 
     const rawContent: RawContent = await this.fileFetcher.fetch(item.url);
 
@@ -57,13 +67,13 @@ export class LocalFileStrategy extends BaseScraperStrategy {
 
     if (!processed) {
       logger.warn(
-        `⚠️ Unsupported content type "${rawContent.mimeType}" for file ${filePath}. Skipping processing.`,
+        `⚠️  Unsupported content type "${rawContent.mimeType}" for file ${filePath}. Skipping processing.`,
       );
       return { document: undefined, links: [] };
     }
 
     for (const err of processed.errors) {
-      logger.warn(`⚠️ Processing error for ${filePath}: ${err.message}`);
+      logger.warn(`⚠️  Processing error for ${filePath}: ${err.message}`);
     }
 
     return {
diff --git a/src/scraper/strategies/WebScraperStrategy.ts b/src/scraper/strategies/WebScraperStrategy.ts
index 47bd21a..abd1ce1 100644
--- a/src/scraper/strategies/WebScraperStrategy.ts
+++ b/src/scraper/strategies/WebScraperStrategy.ts
@@ -91,20 +91,20 @@ export class WebScraperStrategy extends BaseScraperStrategy {
 
       if (!processed) {
         logger.warn(
-          `⚠️ Unsupported content type "${rawContent.mimeType}" for URL ${url}. Skipping processing.`,
+          `⚠️  Unsupported content type "${rawContent.mimeType}" for URL ${url}. Skipping processing.`,
         );
         return { document: undefined, links: [] };
       }
 
       // Log errors from pipeline
       for (const err of processed.errors) {
-        logger.warn(`⚠️ Processing error for ${url}: ${err.message}`);
+        logger.warn(`⚠️  Processing error for ${url}: ${err.message}`);
       }
 
       // Check if content processing resulted in usable content
       if (!processed.textContent || !processed.textContent.trim()) {
         logger.warn(
-          `⚠️ No processable content found for ${url} after pipeline execution.`,
+          `⚠️  No processable content found for ${url} after pipeline execution.`,
         );
         return { document: undefined, links: processed.links };
       }
diff --git a/src/scraper/utils/patternMatcher.ts b/src/scraper/utils/patternMatcher.ts
index d52ca91..08f30da 100644
--- a/src/scraper/utils/patternMatcher.ts
+++ b/src/scraper/utils/patternMatcher.ts
@@ -73,7 +73,26 @@ export function shouldIncludeUrl(
   // Always match from a leading slash for path-based globs
   const path = extractPathAndQuery(url);
   const normalizedPath = path.startsWith("/") ? path : `/${path}`;
-  if (matchesAnyPattern(normalizedPath, excludePatterns)) return false;
+  // For file:// URLs, also match against the basename (strip leading slash from pattern for basename matching)
+  let basename: string | undefined;
+  if (url.startsWith("file://")) {
+    try {
+      const u = new URL(url);
+      basename = u.pathname ? u.pathname.split("/").pop() : undefined;
+    } catch {}
+  }
+  // Helper to strip leading slash from patterns for basename matching
+  const stripSlash = (patterns?: string[]) =>
+    patterns?.map((p) => (p.startsWith("/") ? p.slice(1) : p));
+  // Exclude patterns take precedence
+  if (
+    matchesAnyPattern(normalizedPath, excludePatterns) ||
+    (basename && matchesAnyPattern(basename, stripSlash(excludePatterns)))
+  )
+    return false;
   if (!includePatterns || includePatterns.length === 0) return true;
-  return matchesAnyPattern(normalizedPath, includePatterns);
+  return (
+    matchesAnyPattern(normalizedPath, includePatterns) ||
+    (basename ? matchesAnyPattern(basename, stripSlash(includePatterns)) : false)
+  );
 }
diff --git a/src/store/DocumentManagementService.ts b/src/store/DocumentManagementService.ts
index 17eac30..4d21707 100644
--- a/src/store/DocumentManagementService.ts
+++ b/src/store/DocumentManagementService.ts
@@ -76,7 +76,7 @@ export class DocumentManagementService {
     } catch (error) {
       // Log potential error during directory creation but proceed
       // The DocumentStore constructor might handle DB file creation errors
-      logger.error(`⚠️ Failed to create database directory ${dbDir}: ${error}`);
+      logger.error(`⚠️  Failed to create database directory ${dbDir}: ${error}`);
     }
 
     this.store = new DocumentStore(dbPath);
@@ -126,7 +126,7 @@ export class DocumentManagementService {
     const hasUnversioned = await this.exists(normalizedLibrary, ""); // Check explicitly for unversioned
 
     if (versions.length === 0 && !hasUnversioned) {
-      logger.warn(`⚠️ Library '${library}' not found.`);
+      logger.warn(`⚠️  Library '${library}' not found.`);
 
       // Library doesn't exist, fetch all libraries to provide suggestions
       const allLibraries = await this.listLibraries();
@@ -200,7 +200,7 @@ export class DocumentManagementService {
         return { bestMatch: null, hasUnversioned: true };
       }
       // Throw error only if NO versions (semver or unversioned) exist
-      logger.warn(`⚠️ No valid versions found for ${library}`);
+      logger.warn(`⚠️  No valid versions found for ${library}`);
       // Fetch detailed versions to pass to the error constructor
       const allLibraryDetails = await this.store.queryLibraryVersions();
       const libraryDetails = allLibraryDetails.get(library) ?? [];
@@ -215,7 +215,7 @@ export class DocumentManagementService {
     } else {
       const versionRegex = /^(\d+)(?:\.(?:x(?:\.x)?|\d+(?:\.(?:x|\d+))?))?$|^$/;
       if (!versionRegex.test(targetVersion)) {
-        logger.warn(`⚠️ Invalid target version format: ${targetVersion}`);
+        logger.warn(`⚠️  Invalid target version format: ${targetVersion}`);
         // Don't throw yet, maybe unversioned exists
       } else {
         // Restore the previous logic with fallback
@@ -237,7 +237,7 @@ export class DocumentManagementService {
         `✅ Found best match version ${bestMatch} for ${library}@${targetVersion}`,
       );
     } else {
-      logger.warn(`⚠️ No matching semver version found for ${library}@${targetVersion}`);
+      logger.warn(`⚠️  No matching semver version found for ${library}@${targetVersion}`);
     }
 
     // If no semver match found, but unversioned exists, return that info.
@@ -301,7 +301,7 @@ export class DocumentManagementService {
         path: chunk.section.path,
       },
     }));
-    logger.info(`📄 Split document into ${splitDocs.length} chunks`);
+    logger.info(`✂️  Split document into ${splitDocs.length} chunks`);
 
     // Add split documents to store
     await this.store.addDocuments(library, normalizedVersion, splitDocs);
diff --git a/src/store/applyMigrations.ts b/src/store/applyMigrations.ts
index 49089ac..0e967d2 100644
--- a/src/store/applyMigrations.ts
+++ b/src/store/applyMigrations.ts
@@ -103,7 +103,7 @@ export async function applyMigrations(db: Database): Promise<void> {
       if ((error as any)?.code === "SQLITE_BUSY" && retries < MIGRATION_MAX_RETRIES) {
         retries++;
         logger.warn(
-          `⚠️ Migrations busy (SQLITE_BUSY), retrying attempt ${retries}/${MIGRATION_MAX_RETRIES} in ${MIGRATION_RETRY_DELAY_MS}ms...`,
+          `⚠️  Migrations busy (SQLITE_BUSY), retrying attempt ${retries}/${MIGRATION_MAX_RETRIES} in ${MIGRATION_RETRY_DELAY_MS}ms...`,
         );
         await new Promise((resolve) => setTimeout(resolve, MIGRATION_RETRY_DELAY_MS));
       } else {
diff --git a/src/tools/FetchUrlTool.ts b/src/tools/FetchUrlTool.ts
index 7437dd0..923612a 100644
--- a/src/tools/FetchUrlTool.ts
+++ b/src/tools/FetchUrlTool.ts
@@ -109,7 +109,7 @@ export class FetchUrlTool {
 
       if (!processed) {
         logger.warn(
-          `⚠️ Unsupported content type "${rawContent.mimeType}" for ${url}. Returning raw content.`,
+          `⚠️  Unsupported content type "${rawContent.mimeType}" for ${url}. Returning raw content.`,
         );
         const contentString =
           typeof rawContent.content === "string"
@@ -119,7 +119,7 @@ export class FetchUrlTool {
       }
 
       for (const err of processed.errors) {
-        logger.warn(`⚠️ Processing error for ${url}: ${err.message}`);
+        logger.warn(`⚠️  Processing error for ${url}: ${err.message}`);
       }
 
       if (typeof processed.textContent !== "string" || !processed.textContent.trim()) {
diff --git a/src/web/components/ScrapeFormContent.tsx b/src/web/components/ScrapeFormContent.tsx
index 5dfac2f..b267d12 100644
--- a/src/web/components/ScrapeFormContent.tsx
+++ b/src/web/components/ScrapeFormContent.tsx
@@ -37,7 +37,21 @@ const ScrapeFormContent = () => (
           >
             URL
           </label>
-          <Tooltip text="Enter the URL of the documentation you want to scrape. This will be the starting point for the scraper." />
+          <Tooltip
+            text={
+              <div>
+                <p>Enter the URL of the documentation you want to scrape.</p>
+                <p class="mt-2">
+                  For local files/folders, you must use the <code>file://</code>{" "}
+                  prefix and ensure the path is accessible to the server.
+                </p>
+                <p class="mt-2">
+                  If running in Docker, <b>mount the folder</b> (see README for
+                  details).
+                </p>
+              </div>
+            }
+          />
         </div>
         <input
           type="url"
@@ -50,7 +64,7 @@ const ScrapeFormContent = () => (
           class="mt-0.5 block w-full px-2 py-1 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
         />
         <div
-          x-show="hasPath"
+          x-show="hasPath && !(url.startsWith('file://'))"
           x-cloak
           x-transition:enter="transition ease-out duration-300"
           x-transition:enter-start="opacity-0 transform -translate-y-2"


Test patch:
diff --git a/src/scraper/fetcher/FileFetcher.test.ts b/src/scraper/fetcher/FileFetcher.test.ts
index 7c041fc..b3dae99 100644
--- a/src/scraper/fetcher/FileFetcher.test.ts
+++ b/src/scraper/fetcher/FileFetcher.test.ts
@@ -79,6 +79,36 @@ describe("FileFetcher", () => {
     expect(result.mimeType).toBe("text/markdown");
     // Try to fetch the image: should be detected as binary
     const imageResult = await fetcher.fetch("file:///docs/image.png");
-    expect(imageResult.mimeType).toBe("application/octet-stream");
+    expect(imageResult.mimeType).toBe("image/png");
+  });
+
+  it("should fetch a file with spaces in its name (percent-encoded in file:// URL)", async () => {
+    const fetcher = new FileFetcher();
+    const mockContent = "Hello, space!";
+    vol.fromJSON({
+      "/path with space/file with space.txt": mockContent,
+    });
+    const result = await fetcher.fetch(
+      "file:///path%20with%20space/file%20with%20space.txt",
+    );
+    expect(result.content.toString()).toBe(mockContent);
+    expect(result.source).toBe("file:///path%20with%20space/file%20with%20space.txt");
+  });
+
+  it("should fetch a file with spaces in its name (percent-encoded in file:// URL)", async () => {
+    const filePath = "/tmp/test folder/file with space.md";
+    vol.fromJSON(
+      {
+        [filePath]: "# Hello with space",
+      },
+      "/",
+    );
+    const url = "file:///tmp/test%20folder/file%20with%20space.md";
+    const fetcher = new FileFetcher();
+    const result = await fetcher.fetch(url);
+    expect(result.content.toString()).toBe("# Hello with space");
+    expect(result.mimeType).toBe("text/markdown");
+    expect(result.source).toBe(url);
+    expect(result.encoding).toBe("utf-8");
   });
 });
diff --git a/src/scraper/strategies/LocalFileStrategy.test.ts b/src/scraper/strategies/LocalFileStrategy.test.ts
index 853bc91..40395fa 100644
--- a/src/scraper/strategies/LocalFileStrategy.test.ts
+++ b/src/scraper/strategies/LocalFileStrategy.test.ts
@@ -247,4 +247,92 @@ describe("LocalFileStrategy", () => {
     expect(calledUrls).not.toContain("file:///testdir/file2.png");
     expect(calledUrls).not.toContain("file:///testdir/file4.bin");
   });
+
+  it("should respect include and exclude patterns for local crawling", async () => {
+    const strategy = new LocalFileStrategy();
+    const options: ScraperOptions = {
+      url: "file:///testdir",
+      library: "test",
+      version: "1.0",
+      maxPages: 10,
+      maxDepth: 1,
+      includePatterns: ["/file1.md", "/file3.txt"],
+      excludePatterns: ["/file3.txt"], // exclude takes precedence
+    };
+    const progressCallback = vi.fn();
+    vol.fromJSON(
+      {
+        "/testdir/file1.md": "# File 1", // should be included
+        "/testdir/file2.html": "<html><body>File 2</body></html>", // should be excluded (not in include)
+        "/testdir/file3.txt": "File 3", // should be excluded (in exclude)
+      },
+      "/",
+    );
+    await strategy.scrape(options, progressCallback);
+    // Only file1.md should be processed
+    expect(progressCallback).toHaveBeenCalledTimes(1);
+    const calledUrls = progressCallback.mock.calls.map((call) => call[0].currentUrl);
+    expect(calledUrls).toContain("file:///testdir/file1.md");
+    expect(calledUrls).not.toContain("file:///testdir/file2.html");
+    expect(calledUrls).not.toContain("file:///testdir/file3.txt");
+  });
+
+  it("should process files and folders with spaces in their names (percent-encoded in file:// URL)", async () => {
+    const strategy = new LocalFileStrategy();
+    const options: ScraperOptions = {
+      url: "file:///test%20dir/space%20file.md",
+      library: "test",
+      version: "1.0",
+      maxPages: 1,
+      maxDepth: 0,
+    };
+    const progressCallback = vi.fn();
+    vol.fromJSON(
+      {
+        "/test dir/space file.md": "# Space File\n\nThis file has spaces in its name.",
+      },
+      "/",
+    );
+    await strategy.scrape(options, progressCallback);
+    expect(progressCallback).toHaveBeenCalledTimes(1);
+    expect(progressCallback).toHaveBeenCalledWith(
+      expect.objectContaining({
+        pagesScraped: 1,
+        currentUrl: "file:///test%20dir/space%20file.md",
+        document: expect.objectContaining({
+          content: "# Space File\n\nThis file has spaces in its name.",
+          metadata: expect.objectContaining({
+            url: "file:///test%20dir/space%20file.md",
+            title: "Space File",
+          }),
+        }),
+      }),
+    );
+  });
+
+  it("should decode percent-encoded file paths (spaces as %20) for local crawling", async () => {
+    const strategy = new LocalFileStrategy();
+    const options: ScraperOptions = {
+      url: "file:///test%20dir", // percent-encoded space
+      library: "test",
+      version: "1.0",
+      maxPages: 10,
+      maxDepth: 1,
+      maxConcurrency: 1,
+    };
+    const progressCallback = vi.fn();
+    vol.fromJSON(
+      {
+        "/test dir/file with space.md": "# File With Space",
+        "/test dir/normal.md": "# Normal File",
+      },
+      "/",
+    );
+    await strategy.scrape(options, progressCallback);
+    // Both files should be processed
+    expect(progressCallback).toHaveBeenCalledTimes(2);
+    const calledUrls = progressCallback.mock.calls.map((call) => call[0].currentUrl);
+    expect(calledUrls).toContain("file:///test%20dir/file%20with%20space.md");
+    expect(calledUrls).toContain("file:///test%20dir/normal.md");
+  });
 });