前边介绍了使用 nextjs 搭建的个人博客
这篇文章呢就来聊一聊如何将掘金的文章定时同步到个人博客中
首先需要有一个数据库用来来存储获取到的文章数据,这里使用 mysql 然后使用 nextjs api 获取文章数据使用 prisma 操作数据库存储数据
我们来实现一下
获取掘金文章数据
掘金文章列表接口是一个分页接口
可以看到这个接口需要传入 3 个参数
- cursor: 从第几条数据开始返回 0 就是 0-10 ,10 就是 10-20 每次返回 10 条
- sort_type:排序默认传 2 就行
- user_id: 个人主页 url 后边的那一串数字
我们使用 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 })
}
看一下返回结果,相关字段就不做解释了!
数据入库
这里使用 mysql 结合 prisma 来将获取的数据存储到数据库中
如果你只是想玩一玩可以使用一些云服务提供的数据库比如 prisma.io
prisma 集成
执行 npm install prisma typescript ts-node @types/node --save-dev 安装 prisma
执行 npx prisma init 初始化,这个命令干了两件事
- 创建一个名为
prisma的新目录,其中包含一个名为schema.prisma的文件 - 在项目根目录下创建
.env文件,用于定义环境变量(比如你的数据库连接)
将 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 在数据库中创建文章表
可以看到表已经创建成功!
prisma/migrations 是用于数据库迁移的文件
执行 npx prisma generate 生成 article 表的 ts 类型
将数据入库
新建 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}` })
}
}
调用下看请求是否正常输出,看起来没啥问题
打开数据库发现数据也已正确入库
查询下数量也对的上
定时更新
我们虽然把已有的文章添加到数据库了但是我们的文章是会新增的,总不能新增一次调用一次接口吧
我们需要定时同步文章数据,这里我们使用 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 的参数是怎么设置的呢?
点击 New repository secret
这样就新增了一个可以在 github actions 中使用的密钥了
如果仔细看我个人网站的代码会发现还使用 @vercel/kv 做了一个小时只能调用一次的限制
展示文章-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}` })
}
}
我们请求下看看返回内容
这里拿到的就是直接可以展示 html 元素直接通过 v-html='xxxxx' 展示即可
我们发现代码块的样式很奇怪,这是因为我们在获取 html 把 style 标签去掉了
这样做的原因是为了增加深色浅色主题切换
之前写 nextjs集成掘金 bytemd 编辑器 一文的时候我们给 掘金编辑器增加深色、浅色主题切换的样式,这里可以直接使用
我们加一下,可以看到渲染正常了
代码如下
'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 仓库
本文代码查看 猛击访问