Next.js 定时同步掘金文章到个人博客

1,050 阅读4分钟

前边介绍了使用 nextjs 搭建的个人博客

这篇文章呢就来聊一聊如何将掘金的文章定时同步到个人博客中

首先需要有一个数据库用来来存储获取到的文章数据,这里使用 mysql 然后使用 nextjs api 获取文章数据使用 prisma 操作数据库存储数据

我们来实现一下

获取掘金文章数据

掘金文章列表接口是一个分页接口

image.png

image.png

可以看到这个接口需要传入 3 个参数

  1. cursor: 从第几条数据开始返回 0 就是 0-10 ,10 就是 10-20 每次返回 10 条
  2. sort_type:排序默认传 2 就行
  3. user_id: 个人主页 url 后边的那一串数字

image.png

我们使用 nextjs api 写一个接口封装下请求

app/api/proxy/juejin/articles/route.ts 路径下创建文件,对应接口请求的路径是 ${baseUrl}/api/proxy/juejin/articles 这里需要注意的是 nextjs 接口需要放到 api 目录下,否则无法识别。

import { sendJson } from '@/lib/utils'

// GET 是接口的请求类型 
// 可以是 `GET`、`POST`、`PUT`、`PATCH`、`DELETE`、`HEAD`和`OPTIONS` 。
// https://nextjs.org/docs/app/api-reference/file-conventions/route
export async function GET(req: Request) {
  try {
    // 从 URL 获取查询参数
    const { searchParams } = new URL(req.url)
    const cursor = parseInt(searchParams.get('cursor') || '0')

    const res = await fetch('https://api.juejin.cn/content_api/v1/article/query_list', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        user_id: process.env.JUEJIN_USER_ID, // 掘金的 uid 这里在 env 文件中获取
        sort_type: 2,
        cursor: `${cursor}` // 根据传递的参数获取数据
      })
    })

    const data = await res.json()
    
    // 返回数据
    return sendJson({ data })
  } catch (error) {
    return sendJson({ code: -1, msg: `${error}` })
  }
}

import { sendJson } from '@/lib/utils' 是为了统一返回结果创建的函数

import { NextResponse } from 'next/server'

interface SendJson {
  code?: number
  data?: any
  msg?: string
}
export function sendJson(opts: SendJson) {
  return NextResponse.json({ code: 0, msg: '', ...opts }, { status: 200 })
}

看一下返回结果,相关字段就不做解释了!

image.png

数据入库

这里使用 mysql 结合 prisma 来将获取的数据存储到数据库中

如果你只是想玩一玩可以使用一些云服务提供的数据库比如 prisma.io

image.png

prisma 集成

执行 npm install prisma typescript ts-node @types/node --save-dev 安装 prisma

执行 npx prisma init 初始化,这个命令干了两件事

  1. 创建一个名为prisma的新目录,其中包含一个名为schema.prisma的文件
  2. 在项目根目录下创建.env文件,用于定义环境变量(比如你的数据库连接)

image.png

env 文件中的 DATABASE_URL 修改成自己的数据库连接地址

SITE_URL=http://localhost:3000

# 掘金 userid
JUEJIN_USER_ID=712139266339694

# mysql 数据库连接
DATABASE_URL=mysql://root:root@sql.tencentcdb.com:271244/blogtest

# github repository 
GITHUB_REPOSITORY_API_KEY=QVyKGTfBS0kZy_rAZPkmVs

修改 schema.prisma 文件并增加 Article 表存储文章数据,具体的字段可以根据自己的需求进行存储

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}


model Article {
  id         String    @id @unique
  userId     Int
  title      String    @db.Text
  content    String    @db.LongText
  classify   String?   @db.Text
  coverImg   String?    @db.Text
  summary    String    @db.Text
  source     String?    @db.Text // 数据来源 00 博客创建 01 掘金同步 为了不同的处理
  views      Int       @default(1)
  likes      Int       @default(1)
  favorites  Int       @default(1)
  showNumber Int       @default(1)
  status     String    @db.Text
  createdAt  DateTime  @default(now())
  updatedAt  DateTime? @updatedAt
  deletedAt  DateTime?
  isDeleted  Int?      @default(0)
  @@map("article") // 指定表名
}

执行 npx prisma migrate dev --name create_article_table 在数据库中创建文章表

image.png

可以看到表已经创建成功!

image.png

prisma/migrations 是用于数据库迁移的文件

执行 npx prisma generate 生成 article 表的 ts 类型

image.png

将数据入库

新建 app/api/articles/syncJuejinArticles/route.ts 路径下创建文件,对应接口请求的路径是 ${baseUrl}/api/articles/syncJuejinArticles 写入如下内容

// 导入所需的依赖
import dayjs from 'dayjs'
import { PrismaClient } from '@prisma/client'
import { sendJson } from '@/lib/utils'

// 初始化 Prisma 客户端实例
const prisma = new PrismaClient()

/**
 * 添加或更新文章到数据库
 * @param info 掘金文章信息
 */
async function addArticle(info: any) {
  // 解构文章信息
  const {
    article_id,
    title,
    cover_image,
    brief_content,
    view_count,
    ctime,
    collect_count,
    digg_count
  } = info.article_info

  // 构建文章数据对象
  const data = {
    title: title,
    content: '',          // 文章内容(暂时为空)
    classify: '',         // 文章分类(暂时为空)
    coverImg: cover_image,// 封面图片
    summary: brief_content,// 文章摘要
    status: '',           // 文章状态(暂时为空)
    source: '01',         // 来源标识(01 表示掘金)
    userId: 1,            // 用户ID 这个字段目前看有些多余
    views: view_count,    // 浏览量
    likes: digg_count,    // 点赞数
    favorites: collect_count,// 收藏数
    createdAt: dayjs(ctime * 1000).toDate(),// 创建时间
    updatedAt: dayjs(ctime * 1000).toDate() // 更新时间
  }

  // 使用 upsert 操作:存在则更新,不存在则创建
  await prisma.article.upsert({
    where: { id: article_id },
    update: data,
    create: {
      id: article_id,
      ...data
    }
  })
}

// 用于存储同步的文章标题列表
let syncArticleNameList: string[] = []

/**
 * 递归获取掘金文章列表
 * @param index 分页游标
 */
async function getArticles(index: number) {
  // 调用代理接口获取掘金文章列表
  const res = await fetch(`${process.env.SITE_URL}/api/proxy/juejin/articles?cursor=${index}`).then(
    (res) => res.json()
  )

  // 检查接口返回状态
  if (res?.code !== 0) {
    throw new Error('同步掘金文章失败!')
  }

  const info = res.data

  // 遍历文章列表,添加到数据库
  for (const item of info.data) {
    addArticle(item)
    syncArticleNameList.push(item.article_info.title)
  }

  const nextIndex = index + 10

  // 如果还有更多文章,继续获取下一页
  if (info.has_more) {
    await getArticles(nextIndex)
  }
}

/**
 * GET 请求处理函数
 * 同步掘金文章到数据库
 */
export async function GET(req: Request) {
  // 重置同步文章列表
  syncArticleNameList = []

  // 获取并验证 API 密钥
  const apiKey = req.headers.get('x-api-key')
  const expectedApiKey = process.env.GITHUB_REPOSITORY_API_KEY

  if (!apiKey || apiKey !== expectedApiKey) {
    return sendJson({ code: -1, msg: '无效的 API 密钥' })
  }

  try {
    // 从第一页开始获取文章
    const index = 0
    await getArticles(index)

    console.log(syncArticleNameList)

    // 返回同步成功的文章列表
    return sendJson({ data: syncArticleNameList, msg: '同步掘金文章成功' })
  } catch (error) {
    // 返回错误信息
    return sendJson({ code: -1, msg: `同步掘金文章失败: ${error}` })
  }
}

调用下看请求是否正常输出,看起来没啥问题

image.png

打开数据库发现数据也已正确入库

image.png

查询下数量也对的上

image.png

image.png

定时更新

我们虽然把已有的文章添加到数据库了但是我们的文章是会新增的,总不能新增一次调用一次接口吧

我们需要定时同步文章数据,这里我们使用 github actions 来做个人感觉比较方便

创建 .github/workflows/sync-juejin-articles.yml 文件写入如下内容

name: Sync Juejin Articles

on:
  schedule:
    # 每天凌晨 2 点运行
    - cron: '0 2 * * *'

jobs:
  sync-articles:
    runs-on: ubuntu-latest

    steps:
      - name: Call Sync Juejin Articles API
        env:
          API_KEY: ${{ secrets.API_KEY }}
        run: |
          curl -X GET "https://blog.vaebe.cn/api/articles/syncJuejinArticles" -H "x-api-key: $API_KEY"

https://blog.vaebe.cn 是我个人网站的域名需要替换成自己的

有的小伙伴可能注意到了调用这个接口有一个 x-api-key hender 头需要传它是干什么用的呢?

目的是这个接口不希望所有人都可以调用只有 github actions 才可以

那这个 API_KEY 的参数是怎么设置的呢?

image.png

点击 New repository secret

image.png

这样就新增了一个可以在 github actions 中使用的密钥了

如果仔细看我个人网站的代码会发现还使用 @vercel/kv 做了一个小时只能调用一次的限制

image.png

展示文章-2024-11-09 天塌了掘金加了防爬机制,下边方法失效

有细心的小伙伴发现上边压根就没有存文章详情的内容你怎么展示

这里说明下不是我不存是掘金改成服务端渲染了只能解析 html 获取文章详情如果这样解析后存储问题也不大,但文章的图片有过期时间就难搞了,不能把图片也存一份吧!虽然可以 但是咱不这么干

我们写一个接口根据文章id获取文章详情

安装 npm i cheerio 用来解析 html 文本

新建 app/api/proxy/juejin/details/route.ts 路径下创建文件,对应接口请求的路径是 ${baseUrl}/api/proxy/juejin/details 写入如下内容

import { sendJson } from '@/lib/utils'
import * as cheerio from 'cheerio' // 引入 cheerio 用于解析 HTML

/**
 * 处理掘金文章详情页的 GET 请求
 * @param req Request 对象
 * @returns 返回处理后的文章内容
 */
export async function GET(req: Request) {
  try {
    // 解析请求 URL
    const url = new URL(req.url)
    const id = url.searchParams.get('id') // 从查询参数中获取文章 id

    // 请求掘金原始文章内容
    const res = await fetch(`https://juejin.cn/post/${id}`, {
      headers: {
        'Content-Type': 'text/html; charset=utf-8'
      }
    })

    // 获取响应的 HTML 文本内容
    const htmlContent = await res.text()

    // 使用 cheerio 加载 HTML 内容,用于后续解析
    const $ = cheerio.load(htmlContent)

    // 移除文章中的 style 标签,清理样式
    $('#article-root style').remove()

    // TODO: 获取 read-time 元素中的阅读时间返回

    // 返回处理后的文章 HTML 内容
    return sendJson({ data: $('#article-root').html() })
  } catch (error) {
    // 发生错误时返回错误信息
    return sendJson({ code: -1, msg: `${error}` })
  }
}

我们请求下看看返回内容

image.png

这里拿到的就是直接可以展示 html 元素直接通过 v-html='xxxxx' 展示即可

image.png

我们发现代码块的样式很奇怪,这是因为我们在获取 html 把 style 标签去掉了

image.png

这样做的原因是为了增加深色浅色主题切换

之前写 nextjs集成掘金 bytemd 编辑器 一文的时候我们给 掘金编辑器增加深色、浅色主题切换的样式,这里可以直接使用

我们加一下,可以看到渲染正常了

image.png

代码如下

'use client'

import { useEffect, useState } from 'react'
import '../../components/bytemd/editor.scss'
import '../../components/bytemd/dark-theme.scss'

export default function Component({ params }: { params: { id: string } }) {
  const [details, setDetails] = useState<string>('')

  useEffect(() => {
    async function fetchProxyDetails() {
      const res = await fetch(`/api/proxy/juejin/details?id=${params.id}`).then((res) => res.json())
      if (res.code !== 0) {
        return null
      }
      return res.data
    }

    async function getData() {
      const proxyDetails = await fetchProxyDetails()
        if (proxyDetails) {
          setDetails(proxyDetails)
        }
    }

    getData()
  }, [params.id])

  return (
    <div className="max-w-5xl mx-auto">
      <div dangerouslySetInnerHTML={{ __html: details }} />
    </div>
  )
}

到这里就完整实现了把掘金文章同步到个人博客并展示的逻辑

完整示例可以查看 我的个人博客 github 仓库

本文代码查看 猛击访问

往期文章