我是一个0代码基础和0开发经验的小白,现在我想完全通过Cursor这款工具开发一个"通过URL将网页...
作成日: 2025年1月17日
作成日: 2025年1月17日
我是一个0代码基础和0开发经验的小白,现在我想完全通过Cursor这款工具开发一个"通过URL将网页内容按照原始格式保存到Notion"的web应用。
我的设想是这样的:
<notion 官方 api 参考>
<Create a token>
curl --location --request POST 'https://api.notion.com/v1/oauth/token'
--header 'Authorization: Basic '"$BASE64_ENCODED_ID_AND_SECRET"''
--header 'Content-Type: application/json'
--header 'Notion-Version: 2022-06-28'
--data '{
"grant_type": "authorization_code",
"code": "e202e8c9-0990-40af-855f-ff8f872b1ec6",
"redirect_uri": "https://example.com/auth/notion/callback"
}'
</Create a token>
<gemini 官方 api参考>
API_KEY="YOUR_API_KEY"
curl
-X POST https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${API_KEY}
-H 'Content-Type: application/json'
-d @<(echo '{
"contents": [
{
"role": "user",
"parts": [
{
"text": "你好"
}
]
}
],
"generationConfig": {
"temperature": 0.5,
"topK": 40,
"topP": 0.95,
"maxOutputTokens": 8192,
"responseMimeType": "text/plain"
}
}')
</gemini 官方 api参考>
以上就是整个项目的开发背景以及一些资料,我再次强调我是0代码基础,我只能借助cursor来开发这个应用,所以我希望你先写出整个项目实现的完整流程方案出来,然后将整个方案拆分成一个个小的任务让Cursor去完成,每次只让cursor去完成一个小的任务,这样它才不会产生幻觉,才能出色的完成任务,然后基于上下文再让cursor去完成下一个小任务,如此迭代下去,cursor就能出色的完成整个应用的开发工作,由于cursor是一款搭载了ai语言模型的工具,所以每个任务你都要提供清晰精准的prompt给到cursor,而不是你告诉我我再去告诉cursor。
下面我会先给出整个项目的开发流程与方案,然后再将整个方案拆分成一个个小的任务,并且在每个小任务的后面会提供一个非常具体、清晰、且适合直接放进Cursor的Prompt。这样,你只需要在Cursor里依次完成这些小任务,就可以逐步、稳健地开发出这个"通过URL将网页内容按照原始格式保存到Notion"的web应用。
以下是我们目标应用的大体功能流程:
应用主页(Landing Page)
Notion OAuth2流程
创建“Personal database”
“通过AI一键保存”功能
“使用元素选择器一键保存”功能
交互与界面细节
关键技术点
项目最终交付
下面这些小任务建议你按顺序去让Cursor执行。你可以将### Prompt for Cursor ###部分的内容直接粘贴进Cursor的对话输入框,让它来为你生成需要的文件或代码。
因为你说自己是“0 代码基础”,所以这里提供的Prompt会尽量明确,让Cursor在生成代码后能够自己解释为什么这样写、该怎么使用,并且避免“产生幻觉”或写出与需求不符的部分。
注意:下面的任务与Prompt只是一个示例,你完全可以根据自己的项目需求、技术栈或部署方式,对这些步骤进行微调或改写。如果你发现Cursor输出内容里有和你需求不符的地方,你可以让Cursor进一步“修正”或“优化”。只要思路清晰、目标明确,就能一步步完成。
目标:让Cursor帮你初始化一个Web项目(例如使用 Node.js + Express + React/Vue/Next.js 等皆可)。因为Cursor常用的环境是Node.js/React等,如果你想用Python的Flask/Django也可以,视情况而定。
任务:
Prompt示例(请复制并粘贴到Cursor里):
text### Prompt for Cursor ### 你是我的AI编程助手。我要用Node.js和Express来搭建一个简单的web应用。请帮我完成以下任务: 1. 在当前工作区中创建一个新项目文件夹,例如:notion-web-clipper。 2. 初始化一个Node.js项目,并添加package.json文件。 3. 安装Express依赖包。 4. 创建一个最简单的后端入口文件app.js(或index.js都行),在其中写好基础的Express服务器启动代码,比如监听3000端口,然后在浏览器访问 http://localhost:3000/ 时能返回"Hello Notion"。 5. 在回答的末尾给出详细的使用与运行说明。 请一步一步解释你每一步会怎么做,并且写出对应的关键代码。
目标:让Cursor帮你设计一个最初步的前端页面,用HTML/React/Vue等皆可,显示你想要的页面布局:
先不实现任何真正的功能,只是把界面做出来。
Prompt示例:
text### Prompt for Cursor ### 你是我的AI编程助手。基于上一任务中已经存在的Node.js + Express项目,请帮我完成以下任务: 1. 新建一个简单的前端页面(index.html 或 index.ejs 等),在页面里放置: - “Notion授权”按钮(id="notion-auth-btn") - “创建Notion数据库”按钮(id="create-notion-db-btn") - 一个text类型的输入框(id="gemini-api-key-input")上面写“Gemini API Key:” - 一个text类型的输入框(id="web-url-input")上面写“输入要保存的网页链接:” - 一个按钮(id="save-by-ai-btn",文本是“通过AI一键保存”)下面一行备注:“适合用来保存网络文章” - 一个按钮(id="save-by-selector-btn",文本是“使用元素选择器一键保存”)下面一行备注:“保存除网络文章外的网页内容” 2. 新建一个 /public 或 /static 目录放这份前端文件。让Express可以静态托管这个目录。 3. 将访问 http://localhost:3000/ 时,自动返回该前端页面。 请给出关键的前端和后端代码示例,并且在回答里附上如何在浏览器中查看页面的说明。
目标:让Cursor帮你实现后端的 OAuth2流程:
提示:
.env 文件管理 Notion_CLIENT_ID、Notion_CLIENT_SECRET、以及你的回调URL等。Prompt示例:
text### Prompt for Cursor ### 你是我的AI编程助手。现在请帮我在项目中实现Notion OAuth2授权流程,要求如下: 1. 在 .env 文件里存储NOTION_CLIENT_ID、NOTION_CLIENT_SECRET、NOTION_REDIRECT_URI等配置。 2. 在后端创建一个路由 /auth/notion,用于引导用户跳转到Notion授权页面。授权范围设置为“所有常用的读写权限”,具体可以请你生成示例链接。 3. 在后端创建一个回调路由 /auth/notion/callback,用于接收Notion返回的authorization code,并用它去换取access token。然后将access token暂时先存在服务器内存里(或者存数据库也行)。 4. 完成后,在你的回答里给出具体的代码示例,以及测试方式(如何在浏览器访问,如何看到授权成功)。 请仔细写出每个关键代码段以及解释。
目标:
create database接口在Notion上新建数据库 → 具有标题、URL、关键词、摘要、创建时间 这些属性。Prompt示例:
text### Prompt for Cursor ### 你是我的AI编程助手。现在请帮我实现“创建Personal database”的功能,具体要求: 1. 在前端,当点击 id="create-notion-db-btn" 时,会通过Fetch或Axios请求后端路由 /api/create-database。 2. 后端的 /api/create-database 路由需要调用Notion API来新建一个数据库,名字叫“Personal database”,并且包含以下属性: - Title (title类型) - URL (url类型 或 rich_text类型都可以) - Keywords (multi_select类型 或 rich_text类型) - Summary (rich_text类型) - CreatedTime (date类型) 3. 请给出前端的JS代码和后端的Express代码示例,确保能成功在Notion workspace里生成这个数据库。也请写出怎么获取 parent page id (或 root page id) 的说明。 请把完整的关键代码示例贴出,并解释关键字段。
目标:
Append block children的方式在Notion中呈现正文排版(保留图片/代码块等Block)。提示:
cheerio、puppeteer或类似库。Prompt示例:
text### Prompt for Cursor ### 你是我的AI编程助手。现在请帮我实现“通过AI一键保存”的后端逻辑,具体要求: 1. 在后端创建一个路由 /api/save-by-ai,使用POST请求,接收参数:{ url: '用户输入的网址', geminiApiKey: '用户输入的key' }。 2. 在这个路由里,先用puppeteer或cheerio等爬虫技术获取对应URL的网页全部HTML,并想办法保留原始排版结构。(请在回答里解释你选择用puppeteer还是cheerio,并给出相应代码示例) 3. 调用Gemini-2.0-flash-exp API时,编写一个system级Prompt,引导AI只保留网页正文(文字、图片、代码块),去除冗余;并生成 50字以内摘要 + 5条关键词(与网页核心内容相关)。 4. 接收Gemini返回的内容后,通过Notion API在 “Personal database” 里新建一个Page: - properties: Title(网页标题)、URL、Keywords(数组或字符串)、Summary(50字摘要)、CreatedTime(当前时间) - blocks: 将清洗过的正文结构插入Notion blocks 5. 给出完整的关键后端代码示例,并尽量写详细注释,解释如何做HTTP请求、如何处理Gemini返回的数据、如何把正文写入Notion blocks等。 请按照以上需求,一步步解释实现方式和代码示例。
目标:
提示:
Prompt示例:
text### Prompt for Cursor ### 你是我的AI编程助手。我想实现一个“使用元素选择器一键保存”功能,要求: 1. 点击前端页面上的按钮(id="save-by-selector-btn")后,会在新tab或同窗口打开一个“元素选择器页面”,这个页面里可以输入网址 或 自动加载用户之前输入的网址。 2. 在该“元素选择器页面”里,用户可以选中任意DOM元素(可能多选),高亮显示,并在后台记录这些元素的HTML内容。 3. 用户点击“确认”后,会将所选中的HTML内容post到后端 /api/save-by-selector。 4. 后端在Notion中创建Page,并将这些HTML作为blocks插入数据库里(也带上Title/URL/Keywords/CreatedTime属性的写入示例,可随机设置关键词和摘要或先空置)。 5. 请给出前端JS和后端Express的关键实现示例,并说明如何在浏览器里测试这个流程。 请一步步列出你的思路,给出示例代码并解释实现要点。
目标:
Prompt示例:
text### Prompt for Cursor ### 你是我的AI编程助手。现在请帮我美化前端界面,需求如下: 1. 给主页(index.html 或React页面)添加一些基本的CSS或Tailwind,整体排版更美观,按钮更醒目。 2. 当用户点击“通过AI一键保存”或“使用元素选择器一键保存”并等待响应时,显示一个过渡动画(比如加载spinner)。 3. 在你的回答里给出关键的CSS/JS代码示例,以及你选择用Bootstrap或Tailwind或纯CSS的原因。 4. 说明如何本地运行、在浏览器查看效果。 请提供详细代码,并解释使用要点。
目标:
Prompt示例:
text### Prompt for Cursor ### 你是我的AI编程助手。现在我想把已经开发好的Node.js项目部署到Vercel(或其他平台)。请帮我完成以下任务: 1. 生成一个Dockerfile,让我的项目能够打包成Docker镜像。 2. 如果部署到Vercel,可以简要说明如何配置vercel.json和项目结构,以及如何在Vercel控制台进行设置。 3. 在回答的末尾给出详细的部署步骤说明,包括常见错误排查方式。 请一步步提供关键示例代码和说明。
关于账号信息
.env或项目配置文件里。关于跨域(CORS)
关于错误处理
关于日志和监控
morgan或其他Logger中间件,记录访问日志,方便排查问题。最终成品
只要你遵循这个**“任务拆分 + 精准Prompt + 迭代”**的模式,你就可以最大限度发挥Cursor内置AI的编程能力,降低“幻觉”出现的概率,也更好地保证开发质量。祝你一切顺利,顺利完成这个“网页一键保存到Notion”的应用!
这是一个非常好的问题。确实没有必要让每个使用你应用的用户都去配置自己的 Notion_CLIENT_ID、Notion_CLIENT_SECRET 等敏感信息。一般来说,针对多用户的 OAuth2 授权场景,后端只需要一份「应用级别」的凭证(即你在 Notion 开发者控制台创建应用时获得的 client_id、client_secret)就可以了。
放在后端的环境变量里(.env):
.env 文件或其他类似的配置文件来存储你的 Notion_CLIENT_ID、Notion_CLIENT_SECRET、REDIRECT_URI 等;用户访问你的网站时,看不到这份机密信息:
/auth/notion/callback;后端集成 Notion OAuth2
.env 里写入:
textNOTION_CLIENT_ID=xxxxxx NOTION_CLIENT_SECRET=xxxxxx NOTION_REDIRECT_URI=https://你的应用网址/auth/notion/callback
前端只需要一个“授权”按钮
texthttps://api.notion.com/v1/oauth/authorize ?owner=user &client_id=${NOTION_CLIENT_ID} &redirect_uri=${NOTION_REDIRECT_URI} &response_type=code &state=xxx &scope=xxx
window.location.href = 链接 就可以了。回调、换取 Token
code 参数跳转回 ${NOTION_REDIRECT_URI};client_id、client_secret + code 去 Notion 服务器换取 access_token;其他用户授权时,不需要新填信息
因为真正需要这些配置的只有你——作为应用开发者,需要在后端配置好 OAuth2 的信息。
如果你希望将你的应用开放给他人,让他们拿去自定义自己的 Integration
大多数情况:只需一个Integration
.env 文件时,主要是方便你在开发/部署中管理机密信息,而不是让每位用户都来填写同样的内容。用户甚至不会看到 .env 文件。希望以上说明能解答你的疑惑,也能更好地理解为什么“后端只保留一份 OAuth2 应用配置”就能让所有用户都授权到他们各自的 Notion 账号。
好的,我明白了,如图,我在notion开发者平台里创建了公共集成后,它提供了如图所示的数据参数,这些参数我应该用在哪里?
密钥:secret_8eo8zSkt6d2a0CJKMWgm33LwNY60udm8ecYzSDAW03O
id:17ed872b-594c-806f-93eb-00376c2bd922
在 Notion 开发者平台创建公共集成后,Notion 会给你分配三类关键信息:
另外还会给你一个授权 URL,这是一个示例的跳转链接。下面是它们在代码中的典型使用方式:
最常见的做法是:
.env 文件(不会被提交到代码仓库).env 里,例如:textNOTION_CLIENT_ID=17ed872b-594c-806f-93eb-00376c2bd922 NOTION_CLIENT_SECRET=secret_8eo8zSkt6d2a0CJKMWgm33LwNY60udm8ecYzSDAW03O NOTION_REDIRECT_URI=http://localhost:3000/auth/notion/callback
后续在 Node.js 或其他后端语言里,通过读取环境变量的方式来使用。比如在Node.js里用 dotenv:
jsrequire('dotenv').config(); const notionClientId = process.env.NOTION_CLIENT_ID; const notionClientSecret = process.env.NOTION_CLIENT_SECRET; const notionRedirectUri = process.env.NOTION_REDIRECT_URI;
这样就能安全地在本地/服务器上注入这些敏感信息,不用写死在代码里。
当用户点击“Notion授权”按钮时,你需要把用户引导到 Notion 的授权页面。可以这样写一个路由 /auth/notion,用后端或前端拼出授权URL,然后让浏览器跳过去:
jsapp.get('/auth/notion', (req, res) => { // 注意 Notion 要求在查询参数里带上 client_id、redirect_uri、response_type=code const authorizeUrl = `https://api.notion.com/v1/oauth/authorize?client_id=${notionClientId}&response_type=code&owner=user&redirect_uri=${encodeURIComponent(notionRedirectUri)}`; // 直接让用户跳转过去 res.redirect(authorizeUrl); });
在你的截图里,Notion 已经显示了一条类似:
https://api.notion.com/v1/oauth/authorize?client_id=17ed872b-594c-806f-93eb-00376c2bd922&response_type=code&owner=user&redirect_uri=http://localhost:3000/auth/notion/callback
你可以直接使用它,也可以自己在代码中动态生成。
Notion在用户同意授权后,会将 code 参数重定向到你的 redirect_uri(例如/auth/notion/callback)。在这个回调路由里,你可以用 client_id, client_secret, 以及这个 code 去请求 Notion 的 /v1/oauth/token 接口,换回用户的 Access Token。一般写法如下(以Node.js为例):
jsapp.get('/auth/notion/callback', async (req, res) => { try { const { code } = req.query; // Base64 编码 "client_id:client_secret" const authString = Buffer.from( `${notionClientId}:${notionClientSecret}` ).toString('base64'); const tokenResponse = await fetch('https://api.notion.com/v1/oauth/token', { method: 'POST', headers: { 'Authorization': `Basic ${authString}`, // Notion OAuth 需要 Basic auth 'Content-Type': 'application/json', 'Notion-Version': '2022-06-28' }, body: JSON.stringify({ grant_type: 'authorization_code', code: code, redirect_uri: notionRedirectUri }) }); const tokenData = await tokenResponse.json(); // tokenData 里会包含 access_token, workspace_id, workspace_name 等 // 你可以存到数据库或session,以后请求Notion API时使用。 console.log('Notion tokenData:', tokenData); // 最后可以重定向回前端,提示授权成功 res.redirect('/?status=authorized'); } catch (error) { console.error('获取Notion Token失败', error); res.status(500).send('Notion授权失败'); } });
注意:
Authorization: Basic ... 里需要使用「 client_id + ':' + client_secret 」进行 Base64 编码。grant_type 要设为 authorization_code,并且带上 code 与 redirect_uri。/v1/oauth/authorize,用来发起 OAuth 流程,通常你自己在代码中把参数拼到 URL 里。因此,你只需要在后端(.env)中配置好它们,就能正常发起授权、拿回 token 并对用户的 Notion Workspace 进行读写。至于普通用户,他们只需点击“授权”按钮即可,并不需要知道 client_id、client_secret 这些信息。
现在整个项目的开发已经进行到“5. “通过AI一键保存”核心流程(网页内容爬取 + 调用Gemini + 写入Notion)”这一步了,但是我在使用“通过AI一键保存”时,系统发出了如图所示的报错,我让Cursor修改了几次代码都没解决这个问题,下面是目前整个项目的文件代码,请你帮我仔细排查出报错原因并给出解决办法,你还可以排查下项目是否存在其他暂未发现的bug
public\index.html:
<!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Notion Web Clipper</title> <style> body { max-width: 800px; margin: 20px auto; padding: 0 20px; font-family: Arial, sans-serif; } .container { display: flex; flex-direction: column; gap: 20px; } .input-group { display: flex; flex-direction: column; gap: 5px; } .button-group { display: flex; gap: 10px; } .note { font-size: 0.8em; color: #666; margin-top: 5px; } input[type="text"] { padding: 8px; width: 100%; max-width: 400px; } button { padding: 10px 20px; cursor: pointer; } </style> </head> <body> <div class="container"> <div class="button-group"> <button id="notion-auth-btn">Notion授权</button> <button id="create-notion-db-btn">创建Notion数据库</button> </div></body> </html>text<div class="input-group"> <label for="gemini-api-key-input">Gemini API Key:</label> <input type="text" id="gemini-api-key-input"> </div> <div class="input-group"> <label for="web-url-input">输入要保存的网页链接:</label> <input type="text" id="web-url-input"> </div> <div> <button id="save-by-ai-btn">通过AI一键保存</button> <div class="note">适合用来保存网络文章</div> </div> <div> <button id="save-by-selector-btn">使用元素选择器一键保存</button> <div class="note">保存除网络文章外的网页内容</div> </div> </div> <script> // 检查授权状态 async function checkAuthStatus() { const response = await fetch('/api/auth/status'); const { authorized } = await response.json(); const authBtn = document.getElementById('notion-auth-btn'); if (authorized) { authBtn.textContent = 'Notion已授权'; authBtn.disabled = true; } } // 页面加载时检查授权状态 checkAuthStatus(); // Notion授权按钮点击事件 document.getElementById('notion-auth-btn').addEventListener('click', () => { const width = 600; const height = 800; const left = (screen.width / 2) - (width / 2); const top = (screen.height / 2) - (height / 2); const authWindow = window.open( '/auth/notion', 'Notion Authorization', `width=${width},height=${height},left=${left},top=${top}` ); // 监听授权成功消息 window.addEventListener('message', (event) => { if (event.data.type === 'NOTION_AUTH_SUCCESS') { checkAuthStatus(); authWindow.close(); } }); }); // 创建数据库按钮点击事件 document.getElementById('create-notion-db-btn').addEventListener('click', async () => { try { const response = await fetch('/api/create-database', { method: 'POST' }); const data = await response.json(); if (data.success) { alert(`数据库创建成功!\n点击确定将跳转到新创建的数据库。`); window.open(data.database_url, '_blank'); } else { alert('创建失败:' + data.error); } } catch (error) { console.error('Error:', error); alert('创建数据库时发生错误,请查看控制台获取详细信息。'); } }); // 通过AI一键保存按钮点击事件 document.getElementById('save-by-ai-btn').addEventListener('click', async () => { const url = document.getElementById('web-url-input').value; const geminiApiKey = document.getElementById('gemini-api-key-input').value; if (!url || !geminiApiKey) { alert('请输入网址和Gemini API Key'); return; } // 显示加载状态 const saveBtn = document.getElementById('save-by-ai-btn'); const originalText = saveBtn.textContent; saveBtn.textContent = '保存中...'; saveBtn.disabled = true; try { const response = await fetch('/api/save-by-ai', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, geminiApiKey }) }); const data = await response.json(); if (data.success) { alert('保存成功!'); window.open(data.pageUrl, '_blank'); } else { console.error('保存失败:', data); alert(`保存失败:${data.error}\n\n${JSON.stringify(data.details, null, 2)}`); } } catch (error) { console.error('Error:', error); alert(`保存时发生错误:${error.message}\n请查看控制台获取详细信息。`); } finally { // 恢复按钮状态 saveBtn.textContent = originalText; saveBtn.disabled = false; } }); </script>
routes\saveByAi.js:
const express = require('express');
const router = express.Router();
const puppeteer = require('puppeteer');
const { GoogleGenerativeAI } = require('@google/generative-ai');
const axios = require('axios');
// 导入 tokens
const { tokens } = require('../app');
// Gemini系统提示词
const SYSTEM_PROMPT = `你是一个网页内容提取助手。请帮我完成以下任务:
请按以下格式返回:
---TITLE---
{网页标题}
---KEYWORDS---
{关键词1},{关键词2},{关键词3},{关键词4},{关键词5}
---SUMMARY---
{50字摘要}
---CONTENT---
{清洗后的正文内容}`;
// 获取网页内容
async function fetchWebContent(url) {
let browser;
try {
browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
text// 设置页面加载超时 await page.setDefaultNavigationTimeout(30000); // 添加更多的页面错误监听 page.on('error', err => { console.error('Page error:', err); }); page.on('pageerror', err => { console.error('Page error:', err); }); console.log('正在访问页面:', url); await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 }); console.log('页面加载完成,开始提取内容'); const content = await page.evaluate(() => { // 移除干扰元素 document.querySelectorAll('script, style, nav, footer, iframe, .ad').forEach(el => el.remove()); return { title: document.title, content: document.body.innerHTML }; }); return content; } catch (error) { console.error('Error in fetchWebContent:', { message: error.message, stack: error.stack }); throw new Error(`获取网页内容失败: ${error.message}`); } finally { if (browser) { await browser.close(); } }
}
// 调用Gemini API处理内容
async function processWithGemini(content, apiKey) {
try {
console.log('初始化 Gemini API...');
const genAI = new GoogleGenerativeAI(apiKey);
const model = genAI.getGenerativeModel({
model: "gemini-2.0-flash-exp", // 改回 flash-exp 模型
generationConfig: {
temperature: 0.5, // 降低温度以获得更稳定的输出
maxOutputTokens: 2048, // 减小输出token限制
topK: 40,
topP: 0.95,
},
});
textconsole.log('准备发送内容到 Gemini...'); // 进一步限制内容长度,因为flash模型通常接受更短的输入 const truncatedContent = content.substring(0, 15000); // 减小输入长度限制 const prompt = { contents: [{ parts: [{ text: `${SYSTEM_PROMPT}\n\n${truncatedContent}` }] }] }; console.log('调用 Gemini API...'); const result = await model.generateContent(prompt); console.log('Gemini API 响应成功'); const response = await result.response; const text = response.text(); // 解析Gemini返回的内容 const sections = text.split('---').filter(Boolean); const parsed = {}; sections.forEach(section => { const [type, ...content] = section.trim().split('\n'); parsed[type.toLowerCase()] = content.join('\n').trim(); }); // 确保所有必要的字段都存在 const defaultData = { title: '未知标题', keywords: '关键词解析失败', summary: '摘要生成失败', content: '内容处理失败' }; return { ...defaultData, ...parsed }; } catch (error) { console.error('Gemini API 处理失败:', { error: error, message: error.message, details: error.details, apiKey: apiKey ? '已提供' : '未提供' }); // 抛出更详细的错误 throw new Error(`Gemini API 处理失败: ${error.message}`); }
}
// 创建Notion页面
async function createNotionPage(data, databaseId, notionToken) {
const response = await axios.post('https://api.notion.com/v1/pages', {
parent: { database_id: databaseId },
properties: {
Title: {
title: [{ text: { content: data.title } }]
},
URL: {
url: data.url
},
Keywords: {
rich_text: [{ text: { content: data.keywords } }]
},
Summary: {
rich_text: [{ text: { content: data.summary } }]
},
CreatedTime: {
date: { start: new Date().toISOString() }
}
}
}, {
headers: {
'Authorization': Bearer ${notionToken},
'Notion-Version': '2022-06-28',
'Content-Type': 'application/json'
}
});
text// 添加页面内容 await axios.patch(`https://api.notion.com/v1/blocks/${response.data.id}/children`, { children: convertToNotionBlocks(data.content) }, { headers: { 'Authorization': `Bearer ${notionToken}`, 'Notion-Version': '2022-06-28', 'Content-Type': 'application/json' } }); return response.data;
}
// 将内容转换为Notion blocks
function convertToNotionBlocks(content) {
// 这里需要根据实际内容格式进行转换
// 示例:将markdown转换为Notion blocks
return [{
object: 'block',
type: 'paragraph',
paragraph: {
rich_text: [{ type: 'text', text: { content } }]
}
}];
}
// 主路由处理
router.post('/save-by-ai', async (req, res) => {
const { url, geminiApiKey } = req.body;
text// 验证 API Key if (!geminiApiKey) { return res.status(400).json({ success: false, error: '请提供有效的 Gemini API Key' }); } // 验证 URL if (!url) { return res.status(400).json({ success: false, error: '请提供有效的URL' }); } try { console.log('开始处理URL:', url); // 获取网页内容 const webContent = await fetchWebContent(url); console.log('成功获取网页内容,长度:', webContent.content.length); // 处理内容 console.log('开始调用 Gemini API...'); const processedContent = await processWithGemini(webContent.content, geminiApiKey); console.log('Gemini API 处理完成'); // 从请求对象中获取 tokens const notionToken = req.tokens ? Array.from(req.tokens.values())[0] : null; // 添加 token 检查的日志 console.log('Notion Token:', notionToken ? '存在' : '不存在'); if (!notionToken) { return res.status(401).json({ success: false, error: '未找到 Notion 授权令牌,请先完成授权' }); } // 获取数据库 ID console.log('开始获取 Notion 数据库信息'); const dbResponse = await axios.post('https://api.notion.com/v1/search', { filter: { property: 'object', value: 'database' } }, { headers: { 'Authorization': `Bearer ${notionToken}`, 'Notion-Version': '2022-06-28' } }); const database = dbResponse.data.results.find(db => db.title[0]?.plain_text === 'Personal database' ); if (!database) { throw new Error('未找到 Personal database,请先创建数据库'); } console.log('开始创建 Notion 页面'); const notionPage = await createNotionPage({ title: webContent.title, url, keywords: processedContent.keywords, summary: processedContent.summary, content: processedContent.content }, database.id, notionToken); console.log('保存成功'); res.json({ success: true, pageUrl: notionPage.url }); } catch (error) { console.error('处理失败:', { error: error, message: error.message, url: url, stack: error.stack }); // 返回更友好的错误信息 res.status(500).json({ success: false, error: '保存失败: ' + (error.message || '未知错误'), details: { type: error.name, message: error.message, url: url } }); }
});
module.exports = router;
.env:
NOTION_CLIENT_ID=17ed872b-594c-806f-93eb-00376c2bd922
NOTION_CLIENT_SECRET=secret_8eo8zSkt6d2a0CJKMWgm33LwNY60udm8ecYzSDAW03O
NOTION_REDIRECT_URI=http://localhost:3000/auth/notion/callback
app.js:
const express = require('express');
const axios = require('axios');
require('dotenv').config();
const app = express();
const port = 3000;
// 将 tokens 声明移到顶部
const tokens = new Map();
// 导出 tokens
module.exports = { tokens, app };
// 设置静态文件目录
app.use(express.static('public'));
// 添加 express.json() 中间件来解析 JSON 请求体
app.use(express.json());
// Notion授权路由
app.get('/auth/notion', (req, res) => {
// Notion OAuth2授权URL
const notionAuthUrl = 'https://api.notion.com/v1/oauth/authorize';
const scopes = [
'read_user',
'read_databases',
'write_databases',
'read_pages',
'write_pages',
'create_pages'
].join(' ');
const authUrl = ${notionAuthUrl}?client_id=${process.env.NOTION_CLIENT_ID}&redirect_uri=${encodeURIComponent(process.env.NOTION_REDIRECT_URI)}&response_type=code&owner=user&scope=${encodeURIComponent(scopes)};
res.redirect(authUrl);
});
// Notion回调路由
app.get('/auth/notion/callback', async (req, res) => {
const { code } = req.query;
if (!code) {
return res.status(400).send('Authorization code not found');
}
try {
// 使用authorization code换取access token
const response = await axios.post('https://api.notion.com/v1/oauth/token', {
grant_type: 'authorization_code',
code: code,
redirect_uri: process.env.NOTION_REDIRECT_URI
}, {
headers: {
'Authorization': Basic ${Buffer.from({process.env.NOTION_CLIENT_SECRET}).toString('base64')},
'Content-Type': 'application/json'
}
});
textconst { access_token, workspace_id } = response.data; // 存储token(这里简单存储在内存中,实际应该存入数据库) tokens.set(workspace_id, access_token); // 修改前端页面,显示授权成功 res.send(` <script> window.opener && window.opener.postMessage({ type: 'NOTION_AUTH_SUCCESS' }, '*'); window.close(); </script> <h1>授权成功!请关闭此窗口。</h1> `);
} catch (error) {
console.error('Error exchanging code for token:', error.response?.data || error.message);
res.status(500).send('Authorization failed');
}
});
// 获取授权状态的API
app.get('/api/auth/status', (req, res) => {
res.json({ authorized: tokens.size > 0 });
});
// 创建数据库的路由
app.post('/api/create-database', async (req, res) => {
try {
const access_token = Array.from(tokens.values())[0];
textif (!access_token) { return res.status(401).json({ success: false, error: '未找到有效的访问令牌,请先进行Notion授权' }); } // 获取用户信息,包括默认工作区 const userResponse = await axios.get('https://api.notion.com/v1/users/me', { headers: { 'Authorization': `Bearer ${access_token}`, 'Notion-Version': '2022-06-28' } }); // 获取工作区的根页面 const searchResponse = await axios.post('https://api.notion.com/v1/search', { filter: { property: 'object', value: 'page' }, sort: { direction: 'descending', timestamp: 'last_edited_time' } }, { headers: { 'Authorization': `Bearer ${access_token}`, 'Notion-Version': '2022-06-28', 'Content-Type': 'application/json' } }); // 找到工作区根页面(通常是第一个页面) const rootPage = searchResponse.data.results.find(page => page.parent.type === 'workspace' && page.parent.workspace === true ); if (!rootPage) { return res.status(404).json({ success: false, error: '未找到工作区根页面' }); } // 创建数据库 const response = await axios.post('https://api.notion.com/v1/databases', { parent: { type: "page_id", page_id: rootPage.id }, icon: { type: "emoji", emoji: "📝" }, title: [ { type: "text", text: { content: "Personal database" } } ], properties: { "Title": { "title": {} }, "URL": { "url": {} }, "Keywords": { "rich_text": {} }, "Summary": { "rich_text": {} }, "CreatedTime": { "date": {} } } }, { headers: { 'Authorization': `Bearer ${access_token}`, 'Notion-Version': '2022-06-28', 'Content-Type': 'application/json' } }); // 返回成功信息和数据库链接 const databaseId = response.data.id; const databaseUrl = `https://notion.so/${databaseId.replace(/-/g, '')}`; res.json({ success: true, database_id: databaseId, database_url: databaseUrl, message: '数据库创建成功!' }); } catch (error) { console.error('创建数据库失败:', { message: error.message, response: error.response?.data, status: error.response?.status }); res.status(500).json({ success: false, error: error.response?.data?.message || error.message, details: error.response?.data }); }
});
app.use((req, res, next) => {
req.tokens = tokens;
next();
});
const saveByAiRouter = require('./routes/saveByAi');
app.use('/api', saveByAiRouter);
app.listen(port, () => {
console.log(服务器运行在 http://localhost:${port});
});
package.json:
{
"name": "my-express-app",
"version": "1.0.0",
"description": "A simple Express web application",
"main": "app.js",
"scripts": {
"start": "node app.js",
"test": "echo "Error: no test specified" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@google/generative-ai": "^0.21.0",
"axios": "^1.7.9",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"puppeteer": "^24.1.0"
}
}
<gemini 官方 api参考>
API_KEY="YOUR_API_KEY"
curl
-X POST https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${API_KEY}
-H 'Content-Type: application/json'
-d @<(echo '{
"contents": [
{
"role": "user",
"parts": [
{
"text": "你好"
}
]
}
],
"generationConfig": {
"temperature": 0.5,
"topK": 40,
"topP": 0.95,
"maxOutputTokens": 8192,
"responseMimeType": "text/plain"
}
}')
</gemini 官方 api参考>
根据你贴出的所有代码和错误症状,最大的嫌疑在于调用 Gemini API 后解析返回结果的逻辑不符合最新的库用法,导致 processWithGemini() 里拿到的 result.response 并不是一个真正的“fetch Response”,从而 response.text() 或后续 text.split(...) 等操作出错。
下面先给出最常见的根本原因和修复思路,再额外补充其他可能的隐患或小问题,帮助你彻底排查整个项目。
processWithGemini 中对 result.response 的处理方式有误在 processWithGemini(content, apiKey) 里,你写了以下逻辑(简化后):
jsconst result = await model.generateContent(prompt); const response = await result.response; // 假设它是 fetch Response const text = response.text(); // 试图用 text() 读取正文
但实际上,@google/generative-ai 这个库在调用 generateContent 后返回的 result 并不是一个带有 .response 属性、也不是原生 fetch 的响应。它通常会返回一个类似下面结构的对象:
js{ candidates: [ { // 这里通常是模型生成的文本 output: "模型回复的文本内容..." // 或者叫 content/message 视版本而定 } ], filters: [], ... }
TypeError: Cannot read properties of undefined (reading 'text')text.split is not a function关键:你需要先用 console.log(result)(或console.dir(result, {depth: 5}))在本地看看 model.generateContent(prompt) 返回的到底是什么结构,然后按库文档的说明去拿到真正的文本。通常是:
jsconst result = await model.generateContent(prompt); // 一般 result.candidates[0].output 才是大模型给的正文 const text = result.candidates?.[0]?.output || '';
然后你再对这个 text 做后续解析。例如:
js// 示例:获取文本 const text = result.candidates?.[0]?.output ?? ''; // 将文本按你的自定义分隔符 ---TITLE---、---CONTENT--- 等进行拆分 // (你原先写的 sections = text.split('---')... 就可以套用在这里)
如果你确实想用“系统提示+用户内容”那种更原始的HTTP方式,你也可以不依赖@google/generative-ai这个npm库,直接用axios去调用Google的 https://generativelanguage.googleapis.com/v1beta/models/... 接口,再从JSON里取出 candidates[0].content。但既然你已经安装了@google/generative-ai,就尽量使用它的正确API来取文本。
processWithGemini 示例大概可改成:
jsasync function processWithGemini(rawHtml, apiKey) { try { console.log('初始化 Gemini API...'); const genAI = new GoogleGenerativeAI(apiKey); const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash-exp", generationConfig: { temperature: 0.5, maxOutputTokens: 2048, topK: 40, topP: 0.95, }, }); // 截断太长的网页HTML const truncatedContent = rawHtml.substring(0, 15000); const prompt = { contents: [{ parts: [ { text: `${SYSTEM_PROMPT}\n\n${truncatedContent}` } ] }] }; console.log('调用 Gemini API...'); const result = await model.generateContent(prompt); console.log('Gemini API 响应成功, result =', result); // 从 result.candidates[0].output 获取文本 const text = result.candidates?.[0]?.output || ''; // 现在 text 就是真正的模型回复 // 用你的原先“---TITLE--- ... ---CONTENT---”的分隔思路来解析 // 你原本是 text.split('---').filter(Boolean) const sections = text.split('---').filter(Boolean); const parsed = {}; sections.forEach(section => { const [type, ...contentLines] = section.trim().split('\n'); parsed[type.toLowerCase()] = contentLines.join('\n').trim(); }); // 统一补上默认值 const defaultData = { title: '未知标题', keywords: '关键词解析失败', summary: '摘要生成失败', content: '内容处理失败' }; return { ...defaultData, ...parsed }; } catch (error) { console.error('Gemini API 处理失败:', error); throw new Error(`Gemini API 处理失败: ${error.message}`); } }
这才是比较贴近最新 @google/generative-ai npm 包的写法。你的原代码 result.response.text() 这种写法类似老式 fetch Response,对不上该库的实际返回。
headless: 'new'{ headless: 'new' },这是 Puppeteer v19+ 中的新写法,一般不会报错,但如果你的 Node 环境和 Puppeteer 版本比较旧,可能不支持,或者在部分云服务器上有意外问题。若发现 Puppeteer 不兼容,可先改回 { headless: true } 测试。createNotionPage 里使用了 "Title", "URL", "Keywords", "Summary", "CreatedTime" 这五个属性。如果你在create-database时写的是一样的名字就行(目前看起来是一致的)。db.title[0]?.plain_text === 'Personal database' 就匹配正确的数据库。若你曾多次创建,或者又改了它的标题,会找不到数据库并报 未找到 Personal database。Personal database 的数据库,否则 API 搜索必然会失败。tokens.set(workspace_id, access_token) 来存 Token,并在后续 Array.from(tokens.values())[0] 里拿第一条 Token。<input id="gemini-api-key-input">,然后后端才能拿到 geminiApiKey 调用 generateContent。Gemini API Key,可以在后端写死或放.env;若你是给自己用,就现在这种模式也行。修改 processWithGemini 函数:
const response = await result.response; 和后面的 response.text(),转而使用 result.candidates[0].output(或 result.candidates[0].content,具体看你本地 log 下来的字段名)。split('---') 去解析标题/摘要/关键词/正文。确认 Puppeteer 版本 / Node 版本
headless: 'new' 不支持报错,改成 headless: true 试一下。确认 Notion 数据库
"Title", "URL", 等保持一致。再次测试
修复以上问题后,你的“通过AI一键保存”功能通常就能顺利跑通了。祝你调试顺利!
问题还是没得到解决,还是同样的报错,下面是cursor修改后的代码
const express = require('express');
const router = express.Router();
const puppeteer = require('puppeteer');
const { GoogleGenerativeAI } = require('@google/generative-ai');
const axios = require('axios');
// 导入 tokens
const { tokens } = require('../app');
// Gemini系统提示词
const SYSTEM_PROMPT = `你是一个网页内容提取助手。请帮我完成以下任务:
请按以下格式返回:
---TITLE---
{网页标题}
---KEYWORDS---
{关键词1},{关键词2},{关键词3},{关键词4},{关键词5}
---SUMMARY---
{50字摘要}
---CONTENT---
{清洗后的正文内容}`;
// 获取网页内容
async function fetchWebContent(url) {
let browser;
try {
browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
text// 设置页面加载超时 await page.setDefaultNavigationTimeout(30000); // 添加更多的页面错误监听 page.on('error', err => { console.error('Page error:', err); }); page.on('pageerror', err => { console.error('Page error:', err); }); console.log('正在访问页面:', url); await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 }); console.log('页面加载完成,开始提取内容'); const content = await page.evaluate(() => { // 移除干扰元素 document.querySelectorAll('script, style, nav, footer, iframe, .ad').forEach(el => el.remove()); // 获取处理后的HTML const cleanedHtml = document.body.innerHTML; console.log('清理后的HTML内容:', cleanedHtml); // 在浏览器控制台输出 return { title: document.title, content: cleanedHtml }; }); // 在Node.js控制台也输出内容 console.log('获取到的页面标题:', content.title); console.log('获取到的HTML内容长度:', content.content.length); console.log('HTML内容预览(前500字符):', content.content.substring(0, 500)); return content; } catch (error) { console.error('Error in fetchWebContent:', { message: error.message, stack: error.stack }); throw new Error(`获取网页内容失败: ${error.message}`); } finally { if (browser) { await browser.close(); } }
}
// 调用Gemini API处理内容
async function processWithGemini(content, apiKey) {
try {
console.log('初始化 Gemini API...');
const genAI = new GoogleGenerativeAI(apiKey);
const model = genAI.getGenerativeModel({
model: "gemini-2.0-flash-exp",
generationConfig: {
temperature: 0.5,
maxOutputTokens: 2048,
topK: 40,
topP: 0.95,
},
});
textconsole.log('准备发送内容到 Gemini...'); // 限制内容长度 const truncatedContent = content.substring(0, 15000); const prompt = { contents: [{ parts: [{ text: `${SYSTEM_PROMPT}\n\n${truncatedContent}` }] }] }; console.log('调用 Gemini API...'); const geminiResponse = await model.generateContent(prompt); console.log('Gemini API 响应成功, response =', geminiResponse); // 从 geminiResponse.candidates[0].output 获取文本 const text = geminiResponse.candidates?.[0]?.output || ''; console.log('获取到的文本:', text); // 解析返回的文本 const sections = text.split('---').filter(Boolean); const parsed = {}; sections.forEach(section => { const [type, ...contentLines] = section.trim().split('\n'); parsed[type.toLowerCase()] = contentLines.join('\n').trim(); }); // 确保所有必要的字段都存在 const defaultData = { title: '未知标题', keywords: '关键词解析失败', summary: '摘要生成失败', content: '内容处理失败' }; const processedResult = { ...defaultData, ...parsed }; console.log('解析后的结果:', processedResult); return processedResult; } catch (error) { console.error('Gemini API 处理失败:', { error: error, message: error.message, stack: error.stack }); throw new Error(`Gemini API 处理失败: ${error.message}`); }
}
// 创建Notion页面
async function createNotionPage(data, databaseId, notionToken) {
const response = await axios.post('https://api.notion.com/v1/pages', {
parent: { database_id: databaseId },
properties: {
Title: {
title: [{ text: { content: data.title } }]
},
URL: {
url: data.url
},
Keywords: {
rich_text: [{ text: { content: data.keywords } }]
},
Summary: {
rich_text: [{ text: { content: data.summary } }]
},
CreatedTime: {
date: { start: new Date().toISOString() }
}
}
}, {
headers: {
'Authorization': Bearer ${notionToken},
'Notion-Version': '2022-06-28',
'Content-Type': 'application/json'
}
});
text// 添加页面内容 await axios.patch(`https://api.notion.com/v1/blocks/${response.data.id}/children`, { children: convertToNotionBlocks(data.content) }, { headers: { 'Authorization': `Bearer ${notionToken}`, 'Notion-Version': '2022-06-28', 'Content-Type': 'application/json' } }); return response.data;
}
// 将内容转换为Notion blocks
function convertToNotionBlocks(content) {
// 这里需要根据实际内容格式进行转换
// 示例:将markdown转换为Notion blocks
return [{
object: 'block',
type: 'paragraph',
paragraph: {
rich_text: [{ type: 'text', text: { content } }]
}
}];
}
// 主路由处理
router.post('/save-by-ai', async (req, res) => {
const { url, geminiApiKey } = req.body;
text// 验证 API Key if (!geminiApiKey) { return res.status(400).json({ success: false, error: '请提供有效的 Gemini API Key' }); } // 验证 URL if (!url) { return res.status(400).json({ success: false, error: '请提供有效的URL' }); } try { console.log('开始处理URL:', url); // 获取网页内容 const webContent = await fetchWebContent(url); console.log('成功获取网页内容,长度:', webContent.content.length); // 处理内容 console.log('开始调用 Gemini API...'); const processedContent = await processWithGemini(webContent.content, geminiApiKey); console.log('Gemini API 处理完成'); // 从请求对象中获取 tokens const notionToken = req.tokens ? Array.from(req.tokens.values())[0] : null; // 添加 token 检查的日志 console.log('Notion Token:', notionToken ? '存在' : '不存在'); if (!notionToken) { return res.status(401).json({ success: false, error: '未找到 Notion 授权令牌,请先完成授权' }); } // 获取数据库 ID console.log('开始获取 Notion 数据库信息'); const dbResponse = await axios.post('https://api.notion.com/v1/search', { filter: { property: 'object', value: 'database' } }, { headers: { 'Authorization': `Bearer ${notionToken}`, 'Notion-Version': '2022-06-28' } }); const database = dbResponse.data.results.find(db => db.title[0]?.plain_text === 'Personal database' ); if (!database) { throw new Error('未找到 Personal database,请先创建数据库'); } console.log('开始创建 Notion 页面'); const notionPage = await createNotionPage({ title: webContent.title, url, keywords: processedContent.keywords, summary: processedContent.summary, content: processedContent.content }, database.id, notionToken); console.log('保存成功'); res.json({ success: true, pageUrl: notionPage.url }); } catch (error) { console.error('处理失败:', { error: error, message: error.message, url: url, stack: error.stack }); // 返回更友好的错误信息 res.status(500).json({ success: false, error: '保存失败: ' + (error.message || '未知错误'), details: { type: error.name, message: error.message, url: url } }); }
});
module.exports = router;
从你贴出的“修改后”代码看,核心调用逻辑似乎已经修正为从 geminiResponse.candidates[0].output 中获取文本,但你依然遇到同样的报错,说明极有可能是调用 Gemini API 的请求本身失败,或返回的数据结构并非你期望的 { candidates: [...] }。当请求失败或响应非预期格式时,geminiResponse.candidates 就会是 undefined,引发后续 Cannot read property '0' of undefined 等报错。
下面是最常见的几种可能原因与对应排查方案。你可以依次检查日志、配置和调试输出,来定位问题。
你使用的是:
jsconst { GoogleGenerativeAI } = require('@google/generative-ai');
而后又做了:
jsconst genAI = new GoogleGenerativeAI(apiKey); const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash-exp" }); const geminiResponse = await model.generateContent(prompt);
但是,@google/generative-ai 这个包尚在快速迭代,和网上各种示例/文档可能存在版本差异。在某些版本里,GoogleGenerativeAI 或 model.generateContent() 并不一定返回 candidates 字段。如果你调用失败,geminiResponse 里可能只是一个错误对象或 {error: {...}}。
解决思路:
先确认使用的包版本:在 package.json 里,你写的是 "@google/generative-ai": "^0.21.0"。
npm ls @google/generative-ai 看看具体安装了哪个版本。generateContent 是否是正确方法。打日志:在调用处加上:
jsconsole.log('geminiResponse =', JSON.stringify(geminiResponse, null, 2));
看它具体返回了什么。如果里面根本没有 candidates,而是 { error: {...} } 或 undefined,就说明请求失败或者调用接口不对。
如果你发现最新版本要求用 model.generateText() 或 model.generateMessage() 而不是 generateContent(),就得改动代码去匹配它。
就算代码写对了,也可能你没有正确开通 Gemini PaLM API 或者没有为 key 分配访问 Gemini-2.0-flash-exp 的权限,导致请求返回 403 / 404 / 400 等错误,最终 candidates 为空。
解决思路:
在 GCP 控制台检查
检查是否“gemini-2.0-flash-exp”可用”
text-bison-001 等公共模型,那尝试调用 “gemini-2.0-flash-exp” 就会失败。model: "models/text-bison-001" 来看看能否成功,以排除是 Key 的问题还是模型的权限问题。看一下实际错误返回
catch (error) 里打印 error.response?.data || error:
jsconsole.error('Gemini API 处理失败, response:', error.response?.data || error);
permission denied、insufficient scope、the model not found、the model is not enabled 等信息,就可以确认是权限或模型问题了。axios 发请求如果你对这个 npm 包不熟悉,或者怀疑封装层有 bug,也可以直接用 axios 调用 REST API。你已经贴了官方 cURL:
bashcurl -X POST \ https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${API_KEY} \ ...
就可以在 Node.js 里复刻这个 HTTP 请求,看看返回是否成功。示例:
jsasync function callGeminiViaRest(apiKey, systemPrompt, userContent) { try { const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${apiKey}`; const body = { contents: [ { parts: [ { text: `${systemPrompt}\n\n${userContent}` } ] } ], generationConfig: { temperature: 0.5, topK: 40, topP: 0.95, maxOutputTokens: 2048, responseMimeType: "text/plain" } }; const resp = await axios.post(url, body, { headers: { 'Content-Type': 'application/json' } }); console.log('Gemini raw response:', resp.data); // 如果成功,会有 resp.data.candidates[0].output const text = resp.data?.candidates?.[0]?.output || ''; return text; } catch (err) { console.error('纯REST调用 Gemini 失败', err.response?.data || err.message); throw err; } }
这样就能直接看到真实返回长什么样,如果成功,就知道 .candidates[0].output 的路径正确;如果失败,就会得到 HTTP 错误和更多错误细节。再结合你已有的 SYSTEM_PROMPT、content.substring(0,15000) 等即可拼出完整调用流程。
headless: 'new':如果你的运行环境(比如 Node 版本、OS)不兼容 Puppeteer 新模式,可能启动失败。先试试 headless: true 看是否仍报错。geminiApiKey 并成功发到后端,就说明HTTP请求没问题;剩下的就取决于后端processWithGemini调用是否成功。console.log(JSON.stringify(geminiResponse, null, 2)) 看看实际是什么:
undefined 或 { error: { ... }}, 说明调用失败;candidates: [],说明虽然成功,但没有候选结果;candidates: [ { output: "..." } ] 就应能解析成功。只要你找到了具体的错误信息(比如 model not found, invalid API key, permission denied, internal error 等),接下去就好办了。当前看来,你描述的“依然是同样的报错”很可能就是candidates拿不到,故而.split(...) 报错。一旦知道请求具体返回什么,就能对症下药地解决。祝你调试顺利!