这个 API 允许网页安全地访问用户本地文件系统,前提是用户必须明确授权。

下面我将为你提供一个完整的、可直接使用的解决方案,包括详细的代码解释和使用方法。


最终效果预览

你会得到一个包含搜索框和搜索结果列表的页面,当你在搜索框中输入关键词时,它会自动扫描同目录下的所有 .html 文件,并在文件名和内容中匹配关键词,最后将结果展示出来。


完整代码 (单个 HTML 文件)

将以下所有代码复制到一个新的文本文件中,将其命名为 search.html(或其他你喜欢的名字),然后将其和你想要搜索的本地网页文件(page1.html, page2.html 等)放在同一个文件夹里。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">本地网页搜索</title>
    <style>
        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
            background-color: #f4f7f6;
            color: #333;
            margin: 0;
            padding: 20px;
        }
        .container {
            max-width: 800px;
            margin: 0 auto;
            background-color: #fff;
            padding: 30px;
            border-radius: 8px;
            box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
        }
        h1 {
            color: #2c3e50;
            text-align: center;
            margin-bottom: 30px;
        }
        #search-container {
            margin-bottom: 20px;
        }
        #search-input {
            width: 100%;
            padding: 12px 15px;
            font-size: 16px;
            border: 2px solid #ddd;
            border-radius: 25px;
            outline: none;
            transition: border-color 0.3s;
        }
        #search-input:focus {
            border-color: #3498db;
        }
        #results-container {
            list-style-type: none;
            padding: 0;
        }
        .result-item {
            padding: 15px;
            border-bottom: 1px solid #eee;
            border-radius: 5px;
            margin-bottom: 10px;
            background-color: #fdfdfd;
            transition: background-color 0.2s;
        }
        .result-item:hover {
            background-color: #f0f8ff;
        }
        .result-item a {
            text-decoration: none;
            color: #3498db;
            font-weight: bold;
            font-size: 18px;
            display: block;
        }
        .result-item a:hover {
            text-decoration: underline;
        }
        .result-snippet {
            color: #666;
            font-size: 14px;
            margin-top: 5px;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }
        .no-results {
            text-align: center;
            color: #999;
            font-style: italic;
            padding: 20px;
        }
        #status {
            text-align: center;
            color: #888;
            font-size: 14px;
            margin-top: 10px;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>🔍 本地网页搜索</h1>
        <div id="search-container">
            <input type="text" id="search-input" placeholder="输入关键词搜索本地网页..." autofocus>
        </div>
        <ul id="results-container">
            <!-- 搜索结果将在这里动态显示 -->
        </ul>
        <div id="status">请输入关键词开始搜索...</div>
    </div>
    <script>
        document.addEventListener('DOMContentLoaded', () => {
            const searchInput = document.getElementById('search-input');
            const resultsContainer = document.getElementById('results-container');
            const statusDiv = document.getElementById('status');
            // 存储所有已加载的网页文件内容
            let indexedFiles = new Map();
            // 初始化:请求访问文件系统并索引文件
            async function init() {
                try {
                    // 1. 请求用户选择一个目录(即存放网页的文件夹)
                    const dirHandle = await window.showDirectoryPicker();
                    // 2. 遍历目录,找到所有 .html 文件
                    for await (const entry of dirHandle.values()) {
                        if (entry.kind === 'file' && entry.name.endsWith('.html')) {
                            const file = await entry.getFile();
                            const contents = await file.text();
                            // 将文件名和内容存储起来,用于后续搜索
                            indexedFiles.set(entry.name, contents);
                        }
                    }
                    statusDiv.textContent = `已加载 ${indexedFiles.size} 个网页文件,请开始搜索,`;
                } catch (error) {
                    // 用户可能取消了选择,或者浏览器不支持该API
                    if (error.name === 'AbortError') {
                        statusDiv.textContent = '操作已取消,请刷新页面并选择包含网页的文件夹。';
                    } else {
                        statusDiv.textContent = `初始化失败: ${error.message}`;
                        console.error('初始化失败:', error);
                    }
                }
            }
            // 搜索函数
            function performSearch(query) {
                if (!query) {
                    resultsContainer.innerHTML = '';
                    statusDiv.textContent = '请输入关键词开始搜索...';
                    return;
                }
                const results = [];
                const lowerCaseQuery = query.toLowerCase();
                // 遍历已索引的文件
                for (const [fileName, fileContent] of indexedFiles) {
                    // 1. 在文件名中搜索
                    if (fileName.toLowerCase().includes(lowerCaseQuery)) {
                        results.push({ fileName, fileContent, matchType: 'filename' });
                        continue; // 如果文件名匹配,就不需要再匹配内容了,提高效率
                    }
                    // 2. 在文件内容中搜索
                    const contentMatch = findSnippet(fileContent, lowerCaseQuery);
                    if (contentMatch) {
                        results.push({ fileName, fileContent, matchType: 'content', snippet: contentMatch });
                    }
                }
                // 显示结果
                displayResults(results, query);
            }
            // 从内容中提取匹配片段
            function findSnippet(content, query, snippetLength = 150) {
                const lowerCaseContent = content.toLowerCase();
                const index = lowerCaseContent.indexOf(query);
                if (index === -1) {
                    return null;
                }
                const start = Math.max(0, index - snippetLength / 2);
                const end = Math.min(content.length, index + query.length + snippetLength / 2);
                const snippet = content.substring(start, end);
                // 高亮显示匹配的词
                const regex = new RegExp(`(${query})`, 'gi');
                const highlightedSnippet = snippet.replace(regex, '<mark>$1</mark>');
                return {
                    text: highlightedSnippet,
                    context: `...${snippet}...`
                };
            }
            // 显示搜索结果到页面上
            function displayResults(results, query) {
                statusDiv.textContent = `找到 ${results.length} 个与 "${query}" 相关的结果,`;
                if (results.length === 0) {
                    resultsContainer.innerHTML = '<li class="no-results">未找到相关结果。</li>';
                    return;
                }
                resultsContainer.innerHTML = '';
                results.forEach(result => {
                    const li = document.createElement('li');
                    li.className = 'result-item';
                    const link = document.createElement('a');
                    link.href = `./${result.fileName}`; // 假设文件在同一目录
                    link.textContent = result.fileName;
                    link.target = '_blank'; // 在新标签页打开
                    const snippetDiv = document.createElement('div');
                    snippetDiv.className = 'result-snippet';
                    if (result.matchType === 'filename') {
                        snippetDiv.textContent = '匹配于文件名';
                    } else if (result.snippet) {
                        // 使用 innerHTML 来显示高亮效果
                        snippetDiv.innerHTML = `...${result.snippet.text}...`;
                    }
                    li.appendChild(link);
                    li.appendChild(snippetDiv);
                    resultsContainer.appendChild(li);
                });
            }
            // 监听搜索框的输入事件
            searchInput.addEventListener('input', (e) => {
                performSearch(e.target.value);
            });
            // 页面加载时,首先初始化文件系统访问
            init();
        });
    </script>
</body>
</html>

如何使用

  1. 创建文件:将上面的代码保存为 search.html 文件。
  2. 准备网页:将你想要搜索的所有本地网页(about.html, products.html, contact.htmlsearch.html 放在同一个文件夹里。
  3. 打开浏览器:用 Chrome 或 Edge 等现代浏览器打开 search.html 文件。
  4. 授权访问
    • 页面加载后,会弹出一个文件选择对话框,提示你“选择一个文件夹”。
    • 请选择你刚刚创建的那个包含 search.html 和其他网页的文件夹
    • 点击“选择”按钮,浏览器会请求你的授权,允许此网页访问你选择的文件夹,请点击“允许”或“授权”。
  5. 开始搜索
    • 授权成功后,页面下方会显示“已加载 X 个网页文件...”。
    • 你可以在顶部的搜索框中输入任何关键词,它会实时搜索文件名和文件内容,并显示结果。
    • 点击搜索结果中的链接,可以在新标签页中打开对应的网页。

代码核心逻辑解析

  1. init() 函数 - 初始化与索引

    • window.showDirectoryPicker(): 这是 File System Access API 的核心,它会弹出一个系统级的文件夹选择对话框,让用户选择一个目录。
    • await dirHandle.values(): 这是一个异步迭代器,可以遍历所选目录中的所有文件和子文件夹。
    • entry.kind === 'file' && entry.name.endsWith('.html'): 我们只关心 .html 类型的文件。
    • await entry.getFile()await file.text(): 获取文件对象,然后读取其全部文本内容。
    • indexedFiles.set(entry.name, contents): 将文件名和其内容存储在一个 Map 对象中,以便后续快速搜索。
  2. performSearch(query) 函数 - 执行搜索

    • 它接收用户输入的查询字符串。
    • 遍历 indexedFiles 这个 Map
    • 首先在 fileName 中进行不区分大小写的搜索。
    • 如果文件名不匹配,再调用 findSnippet() 函数在 fileContent 中搜索。
    • 将所有匹配到的结果(文件名和内容片段)收集到一个 results 数组中。
  3. findSnippet() 函数 - 提取并高亮显示片段

    • 这个函数负责从长文本中截取包含关键词的一小段内容,方便用户预览。
    • 它还会使用 <mark> 标签将匹配到的关键词包裹起来,实现高亮显示效果。
  4. displayResults() 函数 - 渲染结果

    • 它负责将搜索结果数组转换成 HTML 元素,并添加到页面的结果列表中。
    • 为每个结果创建一个 <li> 元素,里面包含一个指向文件的 <a> 链接和一个显示内容片段的 <div>

注意事项和局限性

  • 浏览器兼容性:File System Access API 是一个较新的 Web API,目前主要在 基于 Chromium 的浏览器(Chrome, Edge, Opera) 中得到良好支持。Firefox 和 Safari 支持有限或不支持,在旧版浏览器中,此功能将无法工作。
  • 安全性:由于涉及访问本地文件,此功能必须通过用户明确授权才能启动,这是浏览器为了保护用户隐私而设计的强制性安全措施。
  • 首次加载:用户首次打开 search.html 时,必须手动选择一次文件夹,之后每次刷新页面,只要文件夹内容不变,索引都会被保留(除非用户清除了浏览器数据)。
  • 性能:对于包含大量网页(例如几百个)的文件夹,首次索引和搜索可能会有轻微的延迟,但对于个人项目或小型网站来说,性能是完全足够的。