背景
去年体验Nextjs
时写了一篇文章 使用Nextjs快速开发全栈导航网站 , 对应Github
地址可在这里查看,那时候导航网址数据是自己一条条收集的,具体数据在这查看,在这之后,也有很多想收藏推荐的网址,虽然可以弄个表单页面提交数据到DB,但是手动收集网页的图标、标题、描述介绍,这个过程就很浪费时间,想想就...
于是更新和维护导航数据的动力没有了,直到看到几个关于书签的项目,包括商业项目 justmark.notelive.cn/ 和开源项目 hoarder.app/, 他们的共同点就是借助AI来收集网页信息,于是我也想能拥有这样的一个功能,于是有了这一篇文章。
阅读源码
于是从学习hoarder 的实现入手,因为它的特点是
- 一键快速保存链接、笔记和图片,自动抓取页面信息,使用AI生成标签
- 支持快速检索
- 完全开源并可以自部署
文件组织结构
接下来分别介绍它的实现方式,从代码组织上看它包括了LandingPage
页面、浏览器插件、书签管理Web
端和App
端。
yaml
packages:
- "packages/*"
- "apps/*"
- "tooling/*"
- "docs"
bash
.
├── apps
│ ├── browser-extension
│ ├── cli
│ ├── landing
│ ├── mobile
│ ├── web
│ └── workers
浏览器插件点击收藏
首先用户操作是从点击浏览器插件发起的,从浏览器入口文件 apps/browser-extension/src/main.tsx
的路由得到,用户点击图标打开的是主页面也就是 SavePage
页面
jsx
// apps/browser-extension/src/main.tsx
function App() {
return (
...
<Routes>
...
**<Route path="/" element={<SavePage />} />**
<Route
path="/bookmark/:bookmarkId"
element={<BookmarkSavedPage />}
/>
<Route
path="/bookmarkdeleted"
element={<BookmarkDeletedPage />}
/>
</Route>
</Routes>
...
);
}
而该页面逻辑非常简单,首先获取当前tab
页面的url
并通过trpc
调用 api.bookmarks.createBookmark
方法
jsx
export default function SavePage() {
const [error, setError] = useState();
const {
data,
mutate: createBookmark,
status,
} = api.bookmarks.createBookmark.useMutation();
useEffect(() => {
async function runSave() {
const [currentTab] = await chrome.tabs.query({
active: true,
lastFocusedWindow: true,
});
const currentUrl = currentTab.url;
createBookmark({
type: "link",
url: currentUrl,
});
}
runSave();
}, [createBookmark]);
switch (status) {
case "success": {
return <Navigate to={`/bookmark/${data.id}`} />;
}
case "pending": {
return (
<div className="flex justify-between text-lg">
<span>Saving Bookmark </span>
<Spinner />
</div>
);
}
case "idle": {
return <div />;
}
}
}
然后定位到server
端的接口方法位于 /packages/trpc/routers/bookmarks.ts
文件中定义的createBookmark
方法,流程主要包括:
请求响应结构定义和参数校验
1、通过 zod 定义请求和响应参数类型, 并能实现参数校验
yaml
export const bookmarksAppRouter = router({
createBookmark: authedProcedure
// 1、通过 [zod](https://zod.dev/) 校验请求和响应参数类型
.input(zNewBookmarkRequestSchema)
.output(
zBookmarkSchema.merge(
z.object({
alreadyExists: z.boolean().optional().default(false),
}),
),
)
...
db插入书签
2、往书签DB表里插入数据
使用Drizzle ORM
工具插操作数据库,Drizzle
和Prisma
都是的TypeScript ORM
工具, 特点:
- 具有类型安全的查询构建器
- 自动生成数据库迁移文件
- 自带可视化数据库管理界面
关于Prisma
的介绍可以参考我的另外一篇文章 面向Node.js和TypeScript的下一代 ORM工具Prisma
关于Prisma
和Drizzle
的差异可以参考官方文档 Drizzle vs Prisma ORM
数据库模型定义位于 packages/db/schema.ts
,根据模型定义可以看出字段含义如下
bookmarks表:
id
:书签的唯一标识符。createdAt
:书签创建的时间。title
:书签的标题。favourited
:表示书签是否被标记为最爱的布尔值。userId
:创建该书签的用户的ID。taggingStatus
:表示书签的标记状态
bookmarkLinks
表:
id
:书签链接的唯一标识符,它应该与bookmarks
表中的id
相关联。url
:书签的URL。title
:标题。description
:链接描述。imageUrl
:图片URL
。favicon
:网站图标。content
:链接内容。htmlContent
:HTML
内容。screenshotAssetId
:截图的资源ID
图片会存储在sqllite
中。fullPageArchiveAssetId
:全页面存档的资源ID
。imageAssetId
:图片的资源ID
。crawledAt
:爬取的时间。crawlStatus
:爬取状态,可能的值包括pending
(待处理),failure
(失败)和success
(成功)。
在这两个表中,bookmarks
表存储了用户书签的关联信息,而 bookmarkLinks
表则存储了关于书签链接的更详细的信息,包括爬取到的链接内容和状态等
jsx
const bookmark = await ctx.db.transaction(async (tx) => {
const bookmark = (
await tx
.insert(bookmarks)
.values({
userId: ctx.user.id,
})
.returning()
)[0];
const link = (
await tx
.insert(bookmarkLinks)
.values({
id: bookmark.id,
url: input.url.trim(),
})
.returning()
)[0];
const content = {
type: "link",
...link,
};
return {
alreadyExists: false,
tags: [] as ZBookmarkTags[],
content,
...bookmark,
};
});
代码看这一步只是创建了用户书签关系和往bookmarkLinks
表中插入了只有url
字段的一条数据
爬取队列提取网页内容
3、往抓取网页信息队列添加任务
jsx
import { Queue } from "bullmq";
export const LinkCrawlerQueue = new Queue(
"link_crawler_queue",
{
connection: redisUrl,
defaultJobOptions: {
attempts: 5,
backoff: {
type: "exponential",
delay: 1000,
},
},
},
);
await LinkCrawlerQueue.add("crawl", {
bookmarkId: bookmark.id,
});
通过 bullmq
创建了队列,并添加了Job
工作任务,主要参数为书签id
查阅 bullmq
官方文档 可以看到,类似于kafka
, BullMQ
是一个基于 Redis
的 Node.js
作业队列库
其主要特性为
- 事件驱动:BullMQ 提供了丰富的事件,你可以监听这些事件来获取作业的状态。
- 并发控制:你可以控制每个队列的并发作业数量。
- 支持多种类型的作业:例如延迟作业、优先级作业、重复作业等。
- 支持 Redis Streams:BullMQ 是第一个完全基于 Redis Streams 的作业队列。
主要用途是在 Node.js
应用中处理后台作业,比如发送邮件、图像处理、数据清洗、数据同步、定时任务等。
上面代码只是添加到队列还未被处理,查阅文档知道每个队列需要关联具体的一个或多个worker
才能开始处理,于是找到代码位于 /apps/workers/main.ts
jsx
async function main() {
logger.info(`Workers version: ${serverConfig.serverVersion ?? "not set"}`);
const [crawler, openai, search] = [
await CrawlerWorker.build(),
OpenAiWorker.build(),
SearchIndexingWorker.build(),
];
await Promise.any([
Promise.all([crawler.run(), openai.run(), search.run()]),
shutdownPromise,
]);
}
可以看到定义了3
个任务处理方法, 其中 CrawlerWorker
定义如下
jsx
export class CrawlerWorker {
static async build() {
// 给puppeteer注册隐藏不被识别为headless插件和广告屏蔽插件
puppeteer.use(StealthPlugin());
puppeteer.use(
AdblockerPlugin({
blockTrackersAndAnnoyances: true,
}),
);
// puppeteer 连接到一个 Chromium 实例地址
await launchBrowser();
logger.info("Starting crawler worker ...");
// 定义队列任务处理函数为runCrawler
const worker = new Worker(
LinkCrawlerQueue.name,
withTimeout(
runCrawler,
),
{
// 并发处理数
concurrency: serverConfig.crawler.numWorkers,
connection: queueConnectionDetails,
autorun: false,
},
);
// 每个任务有多个状态,completed对应任务完成,监听消息
// 并修改数据库bookmarkLinks的crawlStatus字段为 success
worker.on("completed", (job) => {
const jobId = job?.id ?? "unknown";
logger.info(`[Crawler][${jobId}] Completed successfully`);
const bookmarkId = job?.data.bookmarkId;
if (bookmarkId) {
changeBookmarkStatus(bookmarkId, "success");
}
});
worker.on("failed", (job, error) => {
const jobId = job?.id ?? "unknown";
logger.error(`[Crawler][${jobId}] Crawling job failed: ${error}`);
const bookmarkId = job?.data.bookmarkId;
if (bookmarkId) {
changeBookmarkStatus(bookmarkId, "failure");
}
});
return worker;
}
}
runCrawler
方法如下
jsx
async function runCrawler(job) {
const jobId = job.id ?? "unknown";
const { bookmarkId } = job.data;
// crawlPage方法会运行 puppeteer 获取html内容和截图信息,
const {
htmlContent,
screenshot,
url: browserUrl,
} = await crawlPage(jobId, url);
const [meta, readableContent, screenshotAssetId] = await Promise.all([
// 使用 metascraper工具 (https://metascraper.js.org) 提取title description favicon等meta信息
extractMetadata(htmlContent, browserUrl, jobId),
extractReadableContent(htmlContent, browserUrl, jobId),
storeScreenshot(screenshot, userId, jobId),
]);
// 更新db表补充该书签条目的其他字段
await db
.update(bookmarkLinks)
.set({
title: meta.title,
description: meta.description,
imageUrl: meta.image,
favicon: meta.logo,
content: readableContent?.textContent,
htmlContent: readableContent?.content,
screenshotAssetId,
imageAssetId,
crawledAt: new Date(),
})
.where(eq(bookmarkLinks.id, bookmarkId));
// Enqueue openai job
OpenAIQueue.add("openai", {
bookmarkId,
});
// Update the search index
SearchIndexingQueue.add("search_indexing", {
bookmarkId,
type: "index",
});
}
OpenAI队列生成标签
4、往OpenAI队列添加任务
jsx
const TEXT_PROMPT_BASE = `
I'm building a read-it-later app and I need your help with automatic tagging.
Please analyze the text between the sentences "CONTENT START HERE" and "CONTENT END HERE" and suggest relevant tags that describe its key themes, topics, and main ideas.
Aim for a variety of tags, including broad categories, specific keywords, and potential sub-genres. The tags language must be ${serverConfig.inference.inferredTagLang}. If it's a famous website
you may also include a tag for the website. If the tag is not generic enough, don't include it.
The content can include text for cookie consent and privacy policy, ignore those while tagging.
CONTENT START HERE
`;
async function runOpenAI(job) {
const inferTags = () => {
// 组装prompt
const prompt = `
${TEXT_PROMPT_BASE}
URL: ${bookmark.link.url}
Title: ${bookmark.link.title ?? ""}
Description: ${bookmark.link.description ?? ""}
Content: ${content ?? ""}
${TEXT_PROMPT_INSTRUCTIONS}`;
/*
调用openAI并指定返回格式为json格式(https://platform.openai.com/docs/guides/text-generation/json-mode), 链接的话通过网页内容gpt-3.5-turbo-0125推断
图片的使用gpt-4o-2024-05-13模型
*/
const chatCompletion = await this.openAI.chat.completions.create({
messages: [{ role: "system", content: prompt }],
model: serverConfig.inference.textModel,
response_format: { type: "json_object" },
});
const response = chatCompletion.choices[0].message.content;
// 校验返回格式是否正确
const openAIResponseSchema = z.object({
tags: z.array(z.string()),
});
let tags = openAIResponseSchema.parse(JSON.parse(response.response)).tags;
return tags;
}
const tags = inferTags();
// 更新db tags
...
}
建立索引队列
4、添加任务,往搜索引擎中添加书签文档
搜索引擎使用的开源轻量的 MeiliSearch
jsx
import { Queue } from "bullmq";
export const SearchIndexingQueue = new Queue(
"searching_indexing",
{
connection: queueConnectionDetails,
defaultJobOptions: {
attempts: 5,
backoff: {
type: "exponential",
delay: 1000,
},
},
},
);
SearchIndexingQueue.add("search_indexing", {
bookmarkId: bookmark.id,
type: "index",
});
// 绑定SearchIndexingQueue队列处理函数
const worker = new Worker(
SearchIndexingQueue.name,
runSearchIndexing,
{
connection: queueConnectionDetails,
autorun: false,
},
);
// 初始化
import { MeiliSearch } from "meilisearch";
const searchClient = new MeiliSearch({
host: serverConfig.meilisearch.address,
apiKey: serverConfig.meilisearch.key,
});
const BOOKMARKS_IDX_NAME = "bookmarks"
async function runSearchIndexing(
searchClient: NonNullable<Awaited<ReturnType<typeof getSearchIdxClient>>>,
bookmarkId: string,
) {
// 查询书签,with代表连接查询
const bookmark = await db.query.bookmarks.findFirst({
where: eq(bookmarks.id, bookmarkId),
with: {
link: true,
text: true,
asset: true,
tagsOnBookmarks: {
with: {
tag: true,
},
},
},
});
// 通过addDocuments添加一条文档记录,id为书签id
const searchClient = await searchClient.createIndex(BOOKMARKS_IDX_NAME, {
primaryKey: "id",
});
const task = await searchClient.addDocuments(
[
{
id: bookmark.id,
userId: bookmark.userId,
...(bookmark.link
? {
url: bookmark.link.url,
linkTitle: bookmark.link.title,
description: bookmark.link.description,
content: bookmark.link.content,
}
: undefined),
...(bookmark.asset
? {
content: bookmark.asset.content,
metadata: bookmark.asset.metadata,
}
: undefined),
...(bookmark.text ? { content: bookmark.text.text } : undefined),
note: bookmark.note,
title: bookmark.title,
createdAt: bookmark.createdAt.toISOString(),
tags: bookmark.tagsOnBookmarks.map((t) => t.tag.name),
},
],
{
primaryKey: "id",
},
);
}
最后总结下处理流程如下
下面图由 excalidraw.com/ 的文本生成流程图AI生成并修改
实践
回到我的导航项目本身,也是可以借鉴上面无头浏览器进行截图,并抓取网页内容交给OpenAI
来总结出网页的标题,和一段介绍文案就行,处于偷懒的原因,我决定使用Coze
测试一番,
新建了WorkFlow
流水线,使用 JinaAI
的URL_to_Markdown
和GoogleWebSearch
插件(主要用于SEO
较差从html
上获取不到有效信息的辅助),同时添加了一个模型处理,用于总结网页内容,
本来也使用了
URL_to_screenshot
插件来截图,但是后面因为其他原因还是删除未使用,后续会提到
目前使用的prompt
为
As a professional website content summarizer, please create a summary of the provided text in one paragraph, the output should be string, the title should be the website unique name(only one word) , Please respond to me in Chinese. below is the context: {{input}}
As a professional website content summarizer, according to the webpage content and online search results which is the text between the sentences "CONTENT START HERE" and "CONTENT END HERE" , please create a summary in one paragraph for me, the output should be string, the title should be the website unique name(only one word) , Please respond to me in the context primary language. below is the context: CONTENT START HERE
webpage content
{{input}}
online search results
{{search_result}} CONTENT END HER
但是Coze API
只支持将插件暴露为接口进行调用不支持暴露workflow为接口,因此还需要创建一个插件,插件关联上面WorkFlow
在.env
中添加上面环境变量
bash
COZE_API_TOKEN='xxxx'
COZE_BOT_ID='xxxxx'
然后在项目中新建一个API
路由如下,调用Coze API
接口即可
jsx
import { NextResponse } from "next/server";
export async function GET(request) {
const { searchParams } = new URL(request.url);
const url = searchParams.get("url");
const response = await fetch('https://api.coze.com/open_api/v2/chat', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.COZE_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"conversation_id": "123",
"bot_id": process.env.COZE_BOT_ID,
"query": url,
"stream": false
})
})
const result = await response.json();
const answerChoice = result.messages.find(m => m.type === 'answer');
const content = answerChoice.content.replace(/\```(json)?/g, '');
let res = {}
try {
res = JSON.parse(content);
} catch (error) {}
return NextResponse.json(res);
}
同时我还想做一个生成分享网页卡片的一个功能,在将上面数据渲染成dom
然后使用domtoimage
和html2canvas
导出图片时,报错了,因为导出图片前需要fetch
截图资源,但是截图url
地址是 ,是存储在google storage
上的,图片资源是网关访问url
,加载慢,同时也不知道有效期多久,另外还有cors
校验,试了很多办法都没办法正常将该图片对应dom
导出为图片,于是决定这里还是自己使用无头浏览器截图,于是参考了文章 在 Vercel 部署无头浏览器实现网页截图 来自己截图同时将图片上传到cloudflare
上,最后完整代码可以查看 app/api/summarize/route.ts 文件。目前功能如包括
- 支持截图并上传到
cloudflare
存储桶 - 支持生成可定制排版、字体、背景颜色的网页卡片分享图
- 支持提交到后台数据库存储,一键给导航站添加内容
效果截图如下
最后你可以在我的导航网站 webnav.codefe.top/site-card 页面体验目前的效果,对应Github地址在这里, 欢迎大家体验。