最近在研究对chatGpt网页端的反代,也成功在本地实现了反代功能,并通过cloudflared穿透出去了,能够很方便的进行调用,大家想实现反代的可以用node-chatgpt-proxy,如果你不在乎money用的是openAi的api的话,那实现起来就更容易了。毕竟想即要保证对ChatGpt网页端的访问,又要绕过cf反爬虫不是一件容易的事情,还好GitHub上大佬比较多,还有一些分享出来的比较成熟的反代项目→pandora,这是我认为能找到的最为良心的chatgpt反代和整合项目了,原作者还提供了一个web端的反代地址,感兴趣的可以查看项目了解一下。
言归正传,这篇文章是教大家如何使用ChatGpt来实现对博客文章摘要的生成,提升用户浏览博客时的体验。为了做到免费,所以使用的是ChatGpt web端的api,原理也比较简单,通过特定提示词向chatGpt提问,处理返回的sse内容获取最终结果。成品大概长这样:
应用场景以及实现背景
其实是看到有不少大佬的博客都弄了ai摘要,眼馋也想弄一个,能找到的基本都是基于OpenApi的,也就是说是要花钱的(其实也花不了多少钱),但本着学生党抠到底的原则,于是就有了通过ChatGpt Proxy来实现同样功能的想法。当然实现这一功能除了需要一个自建的或者大佬分享的反代服务器地址,还需要搭建一个处理ChatGpt返回内容的中间服务器。这个中间服务器主要负责一下工作:
- 响应前端请求,开始生成摘要
- 像ChatGpt反代地址发送请求,并处理返回内容获取到完整的摘要
- 对摘要进行持久化处理、避免同一文本内容重复的向ChatGpt发起请求
- 实现鉴权认证功能,防止api被盗用
而前端主要负责的是发送请求跟将摘要内容模拟ChatGpt打字机的样式来输出到页面上。
这里后端使用Golang,前端使用SolidJS实现。
后端实现
这里只讲中间服务器的实现、自建反代服务器可以通过Github上那些成熟的项目来实现、或者使用大佬们的反代地址,eg:https://ai.fakeopen.com/api/conversation
对接ChatGpt
首先,我们可以通过与ChatGpt网页版对话来确定一个提示词,我使用的提示词是这样的:
生成这篇文章的中文摘要,字数不能超过150个汉字,要求以"这篇文章讲了"开头,尽可能简短,直接输出结果
那么如何通过go处理返回的sse内容呢?
我们都知道ChatGpt网页端的api返回的是sse(Server-Sent Events)的响应,这也就意味着,它是边生成边发送的,而我们只需要它完成之后的那一段数据。我们通过api测试软件,可以看到它所返回的数据是这样的:
不难想到,通过将data: 后面的json数据提取出来,判断is_complete
是否为true
来确定内容的完整性,在将content.part
中的回答结果存储即可。我们可以通过Scanner以及字符串处理来实现这一过程:
defer body.Close()
// 读取sse时间流,获取完整数据
scanner := bufio.NewScanner(body)
var result string
for scanner.Scan() {
eventData := scanner.Text()
// 检查是否是一个完整的事件(通常以 "data: " 开头)
if strings.HasPrefix(eventData, "data: ") {
eventData = strings.TrimPrefix(eventData, "data: ")
status := gjson.Get(eventData, "message.metadata.is_complete")
if status.String() == "true" {
res := gjson.Get(eventData, "message.content.parts.0")
result = res.String()
// 获取到数据之后,就可以跳出扫描了
break
}
}
}
if err := scanner.Err(); err != nil {
return err
}
return result
防盗链、鉴权
由于这个接口毫无疑问是是要暴露给所有浏览者的,难免会碰到别有用心的盗用api。常用的方法是Referer防盗链(比如TianliGpt
),但这种也是只是让小偷的盗窃过程多了一两步,因为Referer太好伪造了。
我们要从请求的本质上入手,因为这是一个暴露的api,类似于普通的Get请求,但不同点在于,Get请求是一般请求服务器上的固定资源,只需要设定一些响应阈值即可,对静态资源的请求并不需要消耗大量服务器的资源,而我们的接口是通过文本内容来生成摘要的,也就是处理持久化的部分,新的内容的生成是非静态的,如果请求体中的文本内容不同那么就要回源让服务器来操作。比如ThanliGpt,它使用的是Get请求,并将文本内容和key防在Query中,也就是说,如果我想盗用别人的ThanliGpt接口,只需要修改文本内容再将Referer改成对方的即可,非常轻松。我认为,这种本来就需要通过前端显式调用的接口使用Referer防盗链的方式很鸡肋。
既然是Get请求,为了避免别人用我们的接口去实现他的功能,我们只需要对请求体进行限制就好了。将文本内容放在请求体中显然不靠谱,我们可以将我们文章的链接放在请求体中,后端通过爬取链接的内容来获取文本数据再进行摘要生成,并且这样也可以更方便的进行持久化存储,通过判断请求体中的链接是否是我们允许的链接来防止api被盗用。这样,别人即使请求我们的api,也只能获取到我们的文章的摘要,而不能生成他想要的东西。
对于golang来说,这一功能可以结合goquery
库来实现。
client := req.C()
resp, err := client.R().Get(url)
if err != nil || resp.StatusCode != 200 {
return errors.new("Fetch content failed!")
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return errors.new"Fetch content failed!"+err.Error())
}
// 获取网页文本内容
content := doc.Find(selector).First().Text()
if strutil.IsBlank(content) {
return errors.new("Fetch content failed! It's empty!")
}
result, err := that.summary(content)
if err != nil {
return fiberResp.ErrorMsg(c, "Summarize failed!")
}
return result
持久化存储
这里使用sqlite3进行持久化处理,使用golang的gorm库很容易实现这一功能。我们可以将url的base64编码作为唯一索引对摘要内容进行存储。
type Summary struct {
Key string `gorm:"primarykey" json:"key"`
Content string `json:"content"`
}
func AddSummary(s *Summary) int {
err := db.Create(&s).Error
if err != nil {
return errmsg.ERROR
}
return errmsg.SUCCESS
}
func GetSummary(key string) (Summary, int) {
var s Summary
err := db.Where("key = ?", key).First(&s).Error
if err == gorm.ErrRecordNotFound {
return s, errmsg.NotFound
}
if err != nil {
return s, errmsg.ERROR
}
return s, errmsg.SUCCESS
}
前端实现
前端那就比较容易了,主要是将所需数据发送给后端和一个界面效果的实现。
打字机效果通过定时器功能实现很方便,当然要注意内容完成后取消定时器,可以通过随机设置SetTimeout
来实现停顿思考的效果。
export default function MainContent() {
let remainTextLength = 0
let typing = false
const [content, setContent] = createSignal('')
const [finished, setFinished] = createSignal(false)
const type = (str: string) => {
if (typing) return
typing = true
const rand = Math.random()
let speed = 15
if (rand > 0.99) {
speed = 650
} else if (rand > 0.9) {
speed = 50
}
setTimeout(() => {
setContent(str)
typing = false
}, speed)
}
getText().then((res) => {
const textLength = res.length
const interval = setInterval(() => {
remainTextLength = textLength - content().length
if (remainTextLength <= 0) {
setFinished(true)
clearInterval(interval)
return
}
const str = res.slice(0, content().length + 1)
type(str)
}, 15)
})
return <div class={finished() ? '' : styles.processing}>{content()}</div>
}