看完一键爬取网页的开源书签产品,于是我也改造了我的导航网站

背景

去年体验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工具插操作数据库,DrizzlePrisma都是的TypeScript ORM工具, 特点:

  • 具有类型安全的查询构建器
  • 自动生成数据库迁移文件
  • 自带可视化数据库管理界面

关于Prisma的介绍可以参考我的另外一篇文章 面向Node.js和TypeScript的下一代 ORM工具Prisma

关于PrismaDrizzle的差异可以参考官方文档 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:链接内容。
  • htmlContentHTML内容。
  • 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官方文档 可以看到,类似于kafkaBullMQ 是一个基于 RedisNode.js 作业队列库

其主要特性为

  1. 事件驱动:BullMQ 提供了丰富的事件,你可以监听这些事件来获取作业的状态。
  2. 并发控制:你可以控制每个队列的并发作业数量。
  3. 支持多种类型的作业:例如延迟作业、优先级作业、重复作业等。
  4. 支持 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流水线,使用 JinaAIURL_to_MarkdownGoogleWebSearch插件(主要用于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然后使用domtoimagehtml2canvas导出图片时,报错了,因为导出图片前需要fetch截图资源,但是截图url地址是 ,是存储在google storage 上的,图片资源是网关访问url,加载慢,同时也不知道有效期多久,另外还有cors校验,试了很多办法都没办法正常将该图片对应dom导出为图片,于是决定这里还是自己使用无头浏览器截图,于是参考了文章 在 Vercel 部署无头浏览器实现网页截图 来自己截图同时将图片上传到cloudflare上,最后完整代码可以查看 app/api/summarize/route.ts 文件。目前功能如包括

  • 支持截图并上传到cloudflare存储桶
  • 支持生成可定制排版、字体、背景颜色的网页卡片分享图
  • 支持提交到后台数据库存储,一键给导航站添加内容

效果截图如下

最后你可以在我的导航网站 webnav.codefe.top/site-card 页面体验目前的效果,对应Github地址在这里, 欢迎大家体验。

相关推荐
IT女孩儿1 小时前
CSS查缺补漏(补充上一条)
前端·css
吃杠碰小鸡2 小时前
commitlint校验git提交信息
前端
虾球xz2 小时前
游戏引擎学习第20天
前端·学习·游戏引擎
我爱李星璇2 小时前
HTML常用表格与标签
前端·html
疯狂的沙粒2 小时前
如何在Vue项目中应用TypeScript?应该注意那些点?
前端·vue.js·typescript
小镇程序员3 小时前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐3 小时前
前端图像处理(一)
前端
程序猿阿伟3 小时前
《智能指针频繁创建销毁:程序性能的“隐形杀手”》
java·开发语言·前端
疯狂的沙粒3 小时前
对 TypeScript 中函数如何更好的理解及使用?与 JavaScript 函数有哪些区别?
前端·javascript·typescript
瑞雨溪3 小时前
AJAX的基本使用
前端·javascript·ajax