前言
我去年的时候, 做了一个浏览器插件, 这个插件支持一键直接将 掘金 文章, 复制成 Markdown 文本。 文章链接在这儿: juejin.cn/post/736349...
效果如下
近期突然想到, 既然可以直接复制粘贴 掘金 的文档, 岂不是稍加改动,我也能复制所有的站点的文档才对。
所以就行动起来了, 改进了一下功能, 效果就是这样子:

使用
这个插件其实是我去年做的, 可以参考 github.com 链接:
加载插件按照以下步骤来即可:
- 访问代码源码
- 下载代码
- 解压 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;