前端使用 js 解析 Chrome书签和生成 Chrome书签

623 阅读5分钟

什么是 Chrome书签

Chrome书签是Google Chrome浏览器中的一项功能,它允许用户保存他们经常访问或感兴趣的网页链接。用户可以将这些网页链接存储在书签栏、其他文件夹或使用关键词进行搜索。

以下是一个典型的Chrome书签HTML文件的示例:

<!DOCTYPE NETSCAPE-Bookmark-file-1>
<!-- This is an automatically generated file.
     It will be read and overwritten.
     DO NOT EDIT! -->
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>
    <DT><H3 ADD_DATE="1700000000" LAST_MODIFIED="1700000000" PERSONAL_TOOLBAR_FOLDER="true">Bookmarks Bar</H3>
    <DL><p>
        <DT><A HREF="https://www.google.com" ADD_DATE="1700000001">Google</A>
        <DT><A HREF="https://www.example.com" ADD_DATE="1700000002">Example</A>
        <DT><H3 ADD_DATE="1700000003" LAST_MODIFIED="1700000003">Folder</H3>
        <DL><p>
            <DT><A HREF="https://www.example.org" ADD_DATE="1700000004">Example Org</A>
        </DL><p>
    </DL><p>
</DL><p>

各个部分的说明:

  1. 文档头部

    • <!DOCTYPE NETSCAPE-Bookmark-file-1>:说明这是一个兼容Netscape的书签文件格式,许多浏览器都使用这种格式。
    • <META> 标签:定义字符编码(一般是 UTF-8)。
    • <TITLE><H1>:文档标题,通常是 "Bookmarks"。
  2. 根层级 <DL> 标签

    • <DL>:定义了书签目录的层级(类似于HTML的有序列表)。
    • <DT>:定义每个项目或文件夹。
  3. 书签项 <A> 标签

    • HREF:书签链接的目标网址。
    • ADD_DATE:书签添加的时间戳(Unix时间戳格式)。
    • LAST_MODIFIED:书签最后修改的时间戳。
  4. 文件夹 <H3> 标签

    • <H3>:文件夹名称。
    • PERSONAL_TOOLBAR_FOLDER:如果存在该属性,表示这是浏览器工具栏上的书签文件夹。
  5. 嵌套 <DL>

    • 文件夹内部可以包含一个嵌套的 <DL>,用来存储该文件夹下的书签或子文件夹。

使用js 生成 Chrome 书签html

数据结构可以根据自身的业务进行调整。

前端数据结构

const bookmarksdata = [
  {
    key:'1',
    label:'类目A',
    children:[
      {
        menuName: '谷歌',
        menuIcon: '',     // Chrome 书签文件中并无图标信息
        menuUrl: 'https://google.com',
        menuCode: 'xx'
      },
      {
        menuName: 'baidu',
        menuIcon: '',     // Chrome 书签文件中并无图标信息
        menuUrl: 'https://baidu.com',
        menuCode: 'xx'
      }
    ]
  },
  {
    key:'2',
    label:'类目B',
    children:[
      {
        menuName: '网址名称',
        menuIcon: '',     // Chrome 书签文件中并无图标信息
        menuUrl: 'https://a.com',
        menuCode: ''
      }
    ]
  }
]

生成函数:

// 生成 Chrome 书签 HTML 格式
function generateBookmarkHTML(bookmarks) {
  let html = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
<!-- This is an automatically generated file.
   It will be read and overwritten.
   DO NOT EDIT! -->
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>`;

  bookmarks.forEach(group => {
    html += `
  <DT><H3>${group.label}</H3>
  <DL><p>`;
    group.children.forEach(item => {
      html += `
      <DT><A HREF="${item.menuUrl}" ADD_DATE="${Date.now()}">${item.menuName}</A>`;
    });
    html += `
  </DL><p>`;
  });

  html += `
</DL><p>`;

  return html;
}

// 导出为 HTML 文件
export function exportBookmarks(bookmarksData = []) {
  const bookmarkHTML = generateBookmarkHTML(bookmarksData);
  const blob = new Blob([bookmarkHTML], { type: 'text/html' });
  const url = URL.createObjectURL(blob);

  const a = document.createElement('a');
  a.href = url;
  a.download = 'bookmarks.html';
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(url);
}

使用js解析 Chrome书签文件

处理带有属性PERSONAL_TOOLBAR_FOLDER="true"的H3标签进行忽略,判断书签栏的类目是网址还是单个网址,然后递归处理,生成想要的数据结构。

/**
 * 解析书签 html 文件
 * @param {*} file 
 * @returns 
 */
function parseBookmarksHTML(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();

    reader.onload = function (event) {
      try {
        const htmlContent = event.target.result;
        const parser = new DOMParser();
        const doc = parser.parseFromString(htmlContent, 'text/html');

        // 找到最外层 DL
        const rootDL = doc.querySelector('DL');
        if (!rootDL) {
          throw new Error('未找到 <DL>,文件可能不是标准 Chrome 书签!');
        }

        // 解析获得所有文件夹,并顺带收集"无文件夹"链接
        const { noFolderLinks, folderList } = parseRootDL(rootDL);

        // 把"无文件夹"链接打包进一个文件夹结构
        const result = [];
        if (noFolderLinks.length > 0) {
          result.push({
            key: generateUUID(),
            label: '未分类',
            children: noFolderLinks,
          });
        }

        // 把真正的文件夹们也放到最外层数组
        result.push(...folderList);

        resolve(result);
      } catch (error) {
        reject(error);
      }
    };

    reader.onerror = () => reject(new Error('文件读取失败'));
    reader.readAsText(file);
  });
}

/**
 * 解析最外层 <DL>,把顶层没有文件夹的 <A> 放进 noFolderLinks,
 * 顶层有文件夹的 <H3> + <DL> 放进 folderList
 *
 * @param {HTMLElement} dlElement
 * @returns {{ noFolderLinks: Array, folderList: Array }}
 */
function parseRootDL(dlElement) {
  const dtElements = dlElement.querySelectorAll(':scope > DT');
  const noFolderLinks = [];
  const folderList = [];

  dtElements.forEach((dt) => {
    const h3 = dt.querySelector(':scope > H3');
    const aTag = dt.querySelector(':scope > A');

    if (h3) {
      // 如果 h3 带有 PERSONAL_TOOLBAR_FOLDER="true" 就跳过"自己",但要继续递归解析它的子 <DL>
      if (h3.getAttribute('PERSONAL_TOOLBAR_FOLDER') === 'true') {
        const childDL = dt.querySelector(':scope > DL');
        if (childDL) {
          // 子DL 里会返回一个数组,既可能含子文件夹({ key, label, children }) 也可能是书签({ menuName, ... })
          const childItems = parseBookmarkDL(childDL);
          // 我们需要把这些子items分拆到 noFolderLinks 或 folderList
          // parseBookmarkDL 里返回的结构是:
          //   - 如果是文件夹: { key, label, children }
          //   - 如果是书签:   { menuName, menuUrl, ... }
          childItems.forEach((item) => {
            if (item.menuUrl) {
              // 这是个链接
              noFolderLinks.push(item);
            } else {
              // 这是个文件夹
              folderList.push(item);
            }
          });
        }
        // 然后跳过"自己"(不 push 任何东西)
        return;
      }

      // --- 如果不是 PERSONAL_TOOLBAR_FOLDER="true",正常处理文件夹 ---
      const folderName = h3.innerText.trim() || '未命名文件夹';
      const childDL = dt.querySelector(':scope > DL');
      folderList.push({
        key: generateUUID(),
        label: folderName,
        children: childDL ? parseBookmarkDL(childDL) : [],
      });
    } 
    else if (aTag) {
      // 顶层直接的链接
      noFolderLinks.push({
        menuName: aTag.innerText.trim() || '未命名书签',
        menuIcon: '',
        menuUrl: aTag.href || '',
        menuCode: generateUUID(),
      });
    }
  });

  return { noFolderLinks, folderList };
}

/**
 * 递归解析子文件夹/链接
 * 这里和之前差不多,只是用于处理子文件夹
 *
 * @param {HTMLElement} dlElement
 * @returns {Array} - 返回子层级的数组(文件夹、链接混合)
 */
function parseBookmarkDL(dlElement) {
  const results = [];
  const dtElements = dlElement.querySelectorAll(':scope > DT');

  dtElements.forEach((dt) => {
    const h3 = dt.querySelector(':scope > H3');
    const aTag = dt.querySelector(':scope > A');

    if (h3) {
      // 如果是 "PERSONAL_TOOLBAR_FOLDER" 那就跳过"自己",把子DL 继续解析合并到当前 results
      if (h3.getAttribute('PERSONAL_TOOLBAR_FOLDER') === 'true') {
        const childDL = dt.querySelector(':scope > DL');
        if (childDL) {
          const childItems = parseBookmarkDL(childDL);
          childItems.forEach((item) => results.push(item));
        }
        return; 
      }

      // 正常子文件夹处理
      const folderName = h3.innerText.trim() || '未命名文件夹';
      const childDL = dt.querySelector(':scope > DL');
      results.push({
        key: generateUUID(),
        label: folderName,
        children: childDL ? parseBookmarkDL(childDL) : []
      });
    }
    else if (aTag) {
      // 书签
      const linkName = aTag.innerText.trim() || '未命名书签';
      const linkUrl = aTag.href || '';
      const iconData = aTag.getAttribute('ICON') || ''; // 如果取不到,默认空字符串
      results.push({
        menuName: linkName,
        menuIcon: iconData,
        menuUrl: linkUrl,
        menuCode: generateUUID()
      });
    }
  });

  return results;
}

/** 简易 UUID 生成 */
function generateUUID() {
  return 'xxxxxxxx-xxxx-xxxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
    const r = Math.random() * 16 | 0;
    const v = c === 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}



// 处理文件上传
async function handleFileUpload(event) {
  return new Promise(async (resolve, reject) => {
    try {
      const file = event.target.files[0];
      
      if (!file) {
        throw new Error('请选择一个书签 HTML 文件');
      }

      // 检查文件类型
      if (!file.name.toLowerCase().endsWith('.html')) {
        throw new Error('请选择正确的 HTML 格式文件');
      }

      // 检查文件大小(例如限制为 10MB)
      if (file.size > 10 * 1024 * 1024) {
        throw new Error('文件大小超过限制(最大10MB)');
      }

      const bookmarks = await parseBookmarksHTML(file);
      resolve(bookmarks);
    } catch (error) {
      reject(error);
    }
  });
}

// 创建文件上传按钮
export function importBookmarks(cb) {
  const uploadInput = document.createElement('input');
  uploadInput.type = 'file';
  uploadInput.accept = '.html';
  
  uploadInput.addEventListener('change', async (e) => {
    try {
      const data = await handleFileUpload(e);
      if (cb) {
        cb(data);
      } else {
        console.warn('未提供回调函数处理导入结果');
      }
    } catch (error) {
      console.error('导入书签失败:', error.message);
      // 这里可以添加UI提示,比如使用 alert 或其他提示组件
      alert(error.message);
    } finally {
      uploadInput.remove();
    }
  });

  uploadInput.click();
}

使用方法,直接调用importBookmarks函数,并传入回调函数即可。

总结

Chrome 浏览器和 Edge浏览器名称有所不同,Chrome书签叫书签栏,Edge浏览器叫收藏夹,区别在于导出的这一行:

  • Chrome
<DT><H3 ADD_DATE="1700000000" LAST_MODIFIED="1700000000" PERSONAL_TOOLBAR_FOLDER="true">书签栏</H3> 
  • Edge
<DT><H3 ADD_DATE="1700000000" LAST_MODIFIED="1700000000" PERSONAL_TOOLBAR_FOLDER="true">收藏夹栏</H3> 

上面的generateBookmarkHTML 函数并没有加入这一行,如果加入这行,当把书签文件导入到 Chrome时会在根目录多生成两个文件夹来嵌套生成的文件夹: 已导入->书签栏,在Edge浏览器中则导入正常,如果有已存在的文件夹则进行合并数据,并不会在书签根级多生成一个文件夹。