【实践篇】【01】我用做了一个插件, 点击复制, 获取当前文章为 Markdown 文档

前言

我去年的时候, 做了一个浏览器插件, 这个插件支持一键直接将 掘金 文章, 复制成 Markdown 文本。 文章链接在这儿: juejin.cn/post/736349...

效果如下

近期突然想到, 既然可以直接复制粘贴 掘金 的文档, 岂不是稍加改动,我也能复制所有的站点的文档才对。

所以就行动起来了, 改进了一下功能, 效果就是这样子:

使用

这个插件其实是我去年做的, 可以参考 github.com 链接:

github.com/pro-collect...

加载插件按照以下步骤来即可:

  1. 访问代码源码
  1. 下载代码
  2. 解压 dist.zip 文件, 然后将解压后的文件,将产物目录导入到浏览器扩展程序页面即可(【添加已解压的扩展程序】)。

聊聊里面的技术

以前聊过这个话题, 可以参考文档, juejin.cn/post/736349...

这次说说以前没有说到的一些内容。

获取当前页面 html

其实实现还是非常的简单, 就是给插件 popup 页面, 一个点击按钮, 这个时候去获取当前页面的 dom 节点。

那是怎么获取的呢?

其实很简单, 我们在点击按钮「复制到粘贴板」的时候, 就直接向当前页面注入 script 然后获取当前页面的 html 文本即可。

typescript 复制代码
const hanldeClickCopy = (api: MessageInstance) => async () => {
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });

  const query = getStorageItem<string>(StorageKey.SELECTED_QUERY_SELECTOR);
  if (tab) {
    const pageUrl = tab.url as string;
    const resContent = await chrome.scripting.executeScript({
      target: { tabId: tab.id as number },
      func: () => document.documentElement.outerHTML,
    });

    // html content
    const htmlContent = resContent[0].result;

    const $ = load(htmlContent);
    // 移除 code 块的 header
    $(".code-block-extension-header").remove();

    const articleKey = query ? query : "#article-root";
    const $article = $(articleKey);
    const articleContent = $article?.html() || "";
    const author = trim($(".author-info-box span.name").text());

    const content = generateMarkdown(articleContent, author, pageUrl);

    if (htmlContent && content) {
      navigator.clipboard
        .writeText(content)
        .then(() => {
          api.info("复制成功。");
        })
        .catch((e) => {
          api.error("复制失败。");
        });
    } else {
      api.warning("复制失败。请检测是否为有效页面。");
    }
  } else {
    api.warning("未找到激活的标签页。");
  }
};

其中最重要的部分就是下面这段代码

typescript 复制代码
    const resContent = await chrome.scripting.executeScript({
      target: { tabId: tab.id as number },
      func: () => document.documentElement.outerHTML,
    });

直接向宿主页面, 丢了一个 function: () => document.documentElement.outerHTML 这样就非常方便的将 html string 放到 resContent 里面去了。

处理 html 字符串 - 神级工具:cheerio

这个时候虽然拿到了 html 字符串, 但是处理起来非常的麻烦, 毕竟都只是 html 字符串文本而已。 这个时候, 我们要介绍一个非常牛掰的工具 cheerio

它可以让我们想 jquery 一样操作 html 字符串。 甚至可以说语法和 jquery 一模一样,没有任何区别。

typescript 复制代码
import { load } from "cheerio";

const $ = load(htmlContent);

// 移除 code 块的 header
$(".code-block-extension-header").remove();

const articleKey = query ? query : "#article-root";
const $article = $(articleKey);
const articleContent = $article?.html() || "";
const author = trim($(".author-info-box span.name").text());

const content = generateMarkdown(articleContent, author, pageUrl);

有了它 , 我们就可以非常方便, 从 html 里面拿到 文章的 dom 节点, 也可以很方便拿到文章的其他信息, 比如:作者、文章创建时间、点赞、收藏量等。

还有一个彩蛋, cheerio 其实也是很多 node 爬虫的最爱, 用于处理爬虫获取的 html 基本上无往不利。

将 html 转换为 markdown

通过上面的步骤, 就已经从 html 中,获取到了最关键的 文章 html 文本了, 接下来就是最核心的将 html 文本转为 markdown, 直接说结论吧, 我这儿使用的是一个三方组件库 html-to-md

但是毕竟是第三方库, 里面有很多一些转换之后了之后可能会有一些异常的地方, 需要自己手动校准一下:

typescript 复制代码
import h2md from "html-to-md";

export const generateMarkdown = (articleContent: string, author: string, href: string): string => {
  const content = h2md(articleContent);

  // 写入文件
  const replaceRegexes = (content: string, regexes: [RegExp, string][]) => {
    return regexes.reduce((acc, [regex, replacement]) => {
      return acc.replace(regex, replacement);
    }, content);
  };

  const regexes: [RegExp, string][] = [
    [/javascriptCopy code/gi, ""],
    [/htmlCopy code/gi, ""],
    [/cssCopy code/gi, ""],
    [/jsCopy code/gi, ""],
    [/jsonCopy code/gi, ""],
    [/shellCopy code/gi, ""],
    [/jsxCopy code/gi, ""],
    [/```js\njs/gi, "```js\n"],
    [/```jsx\njsx/gi, "```jsx\n"],
    [/```tsx\ntsx/gi, "```tsx\n"],
    [/```sql\nsql/gi, "```sql\n"],
    [/```java\njava/gi, "```java\n"],
    [/```python\npython/gi, "```python\n"],
    [/```go\ngo/gi, "```go\n"],
    [/```c\nc/gi, "```c\n"],
    [/```c\+\+\nc\+\+/gi, "```c++\n"],
    [/```ini\nini/gi, "```ini\n"],
    [/```json\njson/gi, "```json\n"],
    [/```html\nhtml/gi, "```html\n"],
    [/```csharp\ncsharp/gi, "```csharp\n"],
    [/```javascript\njavascript/gi, "```javascript\n"],
    [/```typescript\ntypescript/gi, "```typescript\n"],
    [/\\. /gi, ". "],
    [/\\- /gi, "- "],
    [/复制代码|代码解读/gi, ""],
    [/\n## /gi, "\n### "],
  ];

  const markdown = replaceRegexes(content, regexes);
  const desc = `> 作者:${author}               
> 链接:${href}             
> 来源:稀土掘金                  
> 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。                    

---------

`;

  if (author) {
    return desc + markdown;
  }

  return markdown;
};

如何抓取所有的页面

思考

之前我是只支持抓取掘金的页面, 因为掘金的文章 dom query 选择器我是知道的, 写在代码里面的。 近几天灵机一动, 如果我每次都是获取整个页面的 html 页面, 那为何不能直接可以动态 query ? 不同的网站, 让用户自己去获取对应页面的文章 query 岂不是可以一键复制任何页面了?

我这儿说的 query 实际上就是 document.querySelector 的对应的参数。

怎么获取呢? 也比较简单:

然后复制到 插件里面即可

复制完成之后, 点击 「+」就可以了。

这个时候, 就可以对应的不同的站点, 复制不同的文章了

实现

这个实现就没有啥好说的, 比较简单, 其实就是在 pupup 里面实现了一个可以动态添加 option 的 select 即可, 添加的 option 保存到本地。

代码没啥好说的, 直接贴, 没兴趣的, 直接略过就行。

typescript 复制代码
import React, { useRef, useState } from "react";
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
import { Button, Divider, Input, Select, Space } from "antd";
import type { InputRef } from "antd";
import { Tooltip } from "antd";
import { setStorageItem, getStorageItem, removeStorageItem } from "../../../store/index";
import { StorageKey } from "@src/consts";

/**
 * CSS查询选择器管理组件
 * 提供添加、选择和清除CSS选择器的功能,支持本地存储持久化
 * 用于管理页面元素选择器并保存用户偏好设置
 */
const QuerySelectorInput: React.FC = () => {
  /**
   * 已添加的CSS查询选择器列表
   * 从localStorage加载初始化数据,保存用户之前添加的所有选择器
   */
  const [queries, setQueries] = useState<string[]>(() => {
    try {
      return getStorageItem<string[]>(StorageKey.QUERY_SELECTORS) || [];
    } catch (e) {
      console.error("Failed to parse saved queries", e);
      return [];
    }
  });
  /**
   * 当前输入框中的查询选择器文本
   * 临时存储用户正在输入的新选择器内容
   */
  const [currentQuery, setCurrentQuery] = useState("");
  /**
   * 当前选中的查询选择器值
   * 从localStorage加载初始化,保存用户上次选择的选择器
   */
  const [selectedValue, setSelectedValue] = useState<string>(() => {
    try {
      return getStorageItem<string>(StorageKey.SELECTED_QUERY_SELECTOR) || "";
    } catch (e) {
      console.error("Failed to parse saved selected value:", e);
      return "";
    }
  });
  /**
   * 输入框的引用
   * 用于在添加新选择器后聚焦回输入框
   */
  const inputRef = useRef<InputRef>(null);

  /**
   * 处理输入框内容变化事件
   * @param event - 输入框变化事件对象
   */
  const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setCurrentQuery(event.target.value);
  };

  /**
   * 添加新的查询选择器
   * 验证输入不为空且不存在重复后,更新选择器列表并保存到localStorage
   * 清空输入框并重新聚焦
   */
  const addQuery = () => {
    if (currentQuery.trim() && !queries.includes(currentQuery.trim())) {
      const newQueries = [...queries, currentQuery.trim()];
      setQueries(newQueries);
      setStorageItem(StorageKey.QUERY_SELECTORS, newQueries);
      setCurrentQuery("");
      setTimeout(() => {
        inputRef.current?.focus();
      }, 0);
    }
  };

  /**
   * 处理添加按钮点击事件
   * @param e - 鼠标事件对象
   */
  const handleButtonClick = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
    e.preventDefault();
    addQuery();
  };

  /**
   * 处理输入框键盘事件
   * 当按下Enter键时添加新选择器
   * @param e - 键盘事件对象
   */
  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Enter") {
      e.preventDefault();
      addQuery();
    }
  };

  /**
   * 处理选择器选择变化事件
   * 更新选中值并保存到localStorage
   * @param value - 选中的选择器值
   */
  const handleSelectChange = (value: string) => {
    setSelectedValue(value);
    try {
      setStorageItem(StorageKey.SELECTED_QUERY_SELECTOR, value);
    } catch (e) {
      console.error("Failed to save selected value to localStorage:", e);
    }
  };

  /**
   * 清除所有查询选择器
   * 重置状态并从localStorage中移除保存的数据
   */
  const handleClearAll = () => {
    setQueries([]);
    setCurrentQuery("");
    setSelectedValue("");
    removeStorageItem(StorageKey.QUERY_SELECTORS);
    removeStorageItem(StorageKey.SELECTED_QUERY_SELECTOR);
  };

  return (
    <Select
      className="w-[450px]"
      placeholder="选择具体的文章的 dom querySelector 选择器"
      allowClear
      value={selectedValue}
      onChange={handleSelectChange}
      dropdownRender={(menu: React.ReactNode) => (
        <div className="max-h-[150px] overflow-auto">
          {menu}
          <Divider className="my-2" />
          <Space className="p-1 px-2">
            <Input
              placeholder="输入 querySelector 选择器"
              ref={inputRef}
              value={currentQuery}
              onChange={handleQueryChange}
              onKeyDown={handleKeyDown}
              className="w-[350px]"
            />
            <Tooltip title="添加查询选择器">
              <Button type="text" icon={<PlusOutlined />} onClick={handleButtonClick} />
            </Tooltip>

            <Tooltip title="清空所有查询选择器">
              <Button
                type="text"
                size="small"
                danger
                icon={<DeleteOutlined />}
                onClick={handleClearAll}
              />
            </Tooltip>
          </Space>
        </div>
      )}
      options={queries.map((query) => ({ label: query, value: query }))}
    />
  );
};

export default QuerySelectorInput;
相关推荐
快乐点吧11 分钟前
【前端】异步任务风控验证与轮询机制技术方案(通用笔记版)
前端·笔记
pe7er39 分钟前
nuxtjs+git submodule的微前端有没有搞头
前端·设计模式·前端框架
七月的冰红茶1 小时前
【threejs】第一人称视角之八叉树碰撞检测
前端·threejs
爱掉发的小李1 小时前
前端开发中的输出问题
开发语言·前端·javascript
Dolphin_海豚1 小时前
一文理清 node.js 模块查找策略
javascript·后端·前端工程化
祝余呀2 小时前
HTML初学者第四天
前端·html
浮桥3 小时前
vue3实现pdf文件预览 - vue-pdf-embed
前端·vue.js·pdf
七夜zippoe3 小时前
前端开发中的难题及解决方案
前端·问题
晓13134 小时前
JavaScript加强篇——第七章 浏览器对象与存储要点
开发语言·javascript·ecmascript
Hockor4 小时前
用 Kimi K2 写前端是一种什么体验?还支持 Claude Code 接入?
前端