chore: 添加 .gitignore 并更新依赖与文档配置
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
dist/
|
||||||
|
output/
|
||||||
24
SKILL.md
24
SKILL.md
@@ -12,7 +12,8 @@ description: 通过 Gemini 官网(gemini.google.com)执行问答与生图操
|
|||||||
3. 文本问答任务(如"问问Gemini xxx")走 Gemini 文本提问链路。
|
3. 文本问答任务(如"问问Gemini xxx")走 Gemini 文本提问链路。
|
||||||
4. 默认模型:可用列表中最强模型,优先 `Gemini 3.1 Pro`。
|
4. 默认模型:可用列表中最强模型,优先 `Gemini 3.1 Pro`。
|
||||||
5. 执行生图后先向用户回报"正在绘图中",完成后回传图片。
|
5. 执行生图后先向用户回报"正在绘图中",完成后回传图片。
|
||||||
6. **禁止使用浏览器截图(screenshot)获取生成图片**。默认通过 `extractImageBase64()` 从已渲染的 DOM 直接提取图片 Base64 数据,解码后保存到本地再发送给用户;仅当用户明确要求高清/原图时,才调用 `downloadLatestImage()` 走原图下载流程。
|
6. **禁止使用浏览器截图(screenshot)获取生成图片**。默认通过 `ops.extractImageBase64()` 从已渲染的 DOM 直接提取图片 Base64 数据,解码后保存到本地再发送给用户;仅当用户明确要求高清/原图时,才调用 `ops.downloadLatestImage()` 走原图下载流程。
|
||||||
|
7. **只调封装好的方法,禁止自己写 `page.evaluate()`**。所有操作通过 `ops.xxx`(高层业务)或 `operator.xxx`(底层原子)完成。底层已全部走 CDP 协议,无需关心实现细节。直接写 evaluate 既浪费 token 又容易出错。
|
||||||
|
|
||||||
## 任务分流
|
## 任务分流
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ Gemini 页面的操作按钮(`.send-button-container` 内)通过 `aria-label
|
|||||||
| 发送 / Send | `ready` | 输入框有内容,可发送 |
|
| 发送 / Send | `ready` | 输入框有内容,可发送 |
|
||||||
| 停止 / Stop | `loading` | 已发送,正在生成回答 |
|
| 停止 / Stop | `loading` | 已发送,正在生成回答 |
|
||||||
|
|
||||||
可通过 `GeminiOps.getStatus()` 获取当前状态,通过 `GeminiOps.pollStatus()` 分段轮询等待生成完毕。
|
可通过 `ops.getStatus()` 获取当前状态,通过 `ops.pollStatus()` 分段轮询等待生成完毕。
|
||||||
|
|
||||||
### A. 文本问答
|
### A. 文本问答
|
||||||
1. 打开 `https://gemini.google.com`。
|
1. 打开 `https://gemini.google.com`。
|
||||||
@@ -50,25 +51,24 @@ Gemini 页面的操作按钮(`.send-button-container` 内)通过 `aria-label
|
|||||||
4. 将用户提示词原样输入。
|
4. 将用户提示词原样输入。
|
||||||
5. 发送后立即通知用户:正在绘图中。
|
5. 发送后立即通知用户:正在绘图中。
|
||||||
6. **分段轮询等待**(见下方"CDP 保活轮询策略",生图超时上限 120s)。
|
6. **分段轮询等待**(见下方"CDP 保活轮询策略",生图超时上限 120s)。
|
||||||
7. 结果出现后,调用 `GeminiOps.getLatestImage()` 获取最新生成的图片(Gemini 一次只生成一张):
|
7. 结果出现后,调用 `ops.getLatestImage()` 获取最新生成的图片(Gemini 一次只生成一张):
|
||||||
- 返回 `{ok, src, alt, width, height, hasDownloadBtn, debug}`。
|
- 返回 `{ok, src, alt, width, height, hasDownloadBtn}`。
|
||||||
- 定位依据:`<img class="image loaded">` — 只有同时具有 `image` 和 `loaded` 两个 class 的才是已渲染完成的生成图片;DOM 中取最后一个即为最新。
|
- 定位依据:`<img class="image loaded">` — 只有同时具有 `image` 和 `loaded` 两个 class 的才是已渲染完成的生成图片;DOM 中取最后一个即为最新。
|
||||||
- `src` 为 `https://lh3.googleusercontent.com/...` 格式的原图 URL。
|
- `src` 为 `https://lh3.googleusercontent.com/...` 格式的原图 URL。
|
||||||
- 若 `ok === false`,等几秒再调一次;连续两次失败则做 snapshot 排查页面状态。
|
- 若 `ok === false`,等几秒再调一次;连续两次失败则做 screenshot 排查页面状态。
|
||||||
- **默认**:调用 `GeminiOps.extractImageBase64()` 从 DOM 直接提取图片 Base64(Canvas 优先,跨域污染时 fallback 到 fetch),解码后保存为本地文件发送给用户。注意该函数返回 Promise,CDP 调用时需设置 `awaitPromise:true`。
|
- **默认**:调用 `ops.extractImageBase64()` 从 DOM 直接提取图片 Base64(Canvas 优先,跨域污染时 fallback 到 fetch),解码后保存为本地文件发送给用户。
|
||||||
- **高清**:仅当用户明确要求高清/原图时,才调用 `GeminiOps.downloadLatestImage()` 走原图下载按钮流程。
|
- **高清**:仅当用户明确要求高清/原图时,才调用 `ops.downloadLatestImage()` 走原图下载按钮流程。
|
||||||
- 下载按钮定位:从 `img` 向上找到 `.image-container` 容器,容器内的 `mat-icon[fonticon="download"]` 即为下载原图按钮。
|
- 下载按钮定位:从 `img` 向上找到 `.image-container` 容器,容器内的 `mat-icon[fonticon="download"]` 即为下载原图按钮。
|
||||||
- ⚠️ **严禁使用浏览器截图(screenshot)代替保存图片**。
|
- ⚠️ **严禁使用浏览器截图(screenshot)代替保存图片**。
|
||||||
8. 将保存到本地的图片文件发送给用户。
|
8. 将保存到本地的图片文件发送给用户。
|
||||||
9. **将每步操作返回的 `debug` 日志一并回传给用户**,方便排查定位失败和优化策略。所有函数(`probe`、`click`、`fillPrompt`、`pollStatus`、`getLatestImage`、`extractImageBase64`、`downloadLatestImage`)的返回值都包含 `debug` 字段。
|
|
||||||
|
|
||||||
## CDP 保活轮询策略
|
## CDP 保活轮询策略
|
||||||
|
|
||||||
> **核心原则**:绝不在页面内做长时间 Promise 等待。每次 `evaluate` 必须毫秒级返回,由调用端控制循环。
|
> **核心原则**:通过 `ops.pollStatus()` 分段轮询,不要试图一次性长时间等待结果。
|
||||||
|
|
||||||
生图/问答发送后,按以下方式等待结果:
|
生图/问答发送后,按以下方式等待结果:
|
||||||
|
|
||||||
1. 每隔 **8~10 秒**调用一次 `GeminiOps.pollStatus()`。
|
1. 每隔 **8~10 秒**调用一次 `ops.pollStatus()`。
|
||||||
2. 该函数立即返回 `{status, label, pageVisible, ts}`。
|
2. 该函数立即返回 `{status, label, pageVisible, ts}`。
|
||||||
3. 调用端根据 `status` 判断:
|
3. 调用端根据 `status` 判断:
|
||||||
- `loading` → 继续等待,累计已耗时。
|
- `loading` → 继续等待,累计已耗时。
|
||||||
@@ -86,8 +86,8 @@ Gemini 页面的操作按钮(`.send-button-container` 内)通过 `aria-label
|
|||||||
|
|
||||||
## 低 token 优先策略
|
## 低 token 优先策略
|
||||||
|
|
||||||
- 优先使用 `scripts/gemini_ui_shortcuts.js` 的快捷选择器。
|
- **只调封装好的 `ops.xxx` / `operator.xxx` 方法**,不要自己拼 `page.evaluate()` 代码——既省 token 又不容易出错。
|
||||||
- 先 evaluate 批量动作,再 snapshot 精准兜底。
|
- 先调方法执行动作,再用 `operator.screenshot()` 精准兜底排查。
|
||||||
- 避免高频全量快照。
|
- 避免高频全量快照。
|
||||||
|
|
||||||
## 参考
|
## 参考
|
||||||
|
|||||||
1414
package-lock.json
generated
Normal file
1414
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "gemini-skill",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"demo": "node src/demo.js",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"gemini",
|
||||||
|
"cdp",
|
||||||
|
"puppeteer",
|
||||||
|
"ai-image-generation"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "通过 CDP 操控 Gemini 网页进行 AI 问答与生图",
|
||||||
|
"dependencies": {
|
||||||
|
"puppeteer-core": "^24.39.1",
|
||||||
|
"puppeteer-extra": "^3.3.6",
|
||||||
|
"puppeteer-extra-plugin-stealth": "^2.11.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
(function initGeminiOps(){
|
|
||||||
const S = {
|
|
||||||
promptInput: [
|
|
||||||
'div.ql-editor[contenteditable="true"][role="textbox"]',
|
|
||||||
'[contenteditable="true"][aria-label*="Gemini"]',
|
|
||||||
'[contenteditable="true"][data-placeholder*="Gemini"]',
|
|
||||||
'div[contenteditable="true"][role="textbox"]'
|
|
||||||
],
|
|
||||||
actionBtn: [
|
|
||||||
'.send-button-container button.send-button',
|
|
||||||
'.send-button-container button'
|
|
||||||
],
|
|
||||||
newChatBtn: [
|
|
||||||
'[data-test-id="new-chat-button"] a',
|
|
||||||
'[data-test-id="new-chat-button"]',
|
|
||||||
'a[aria-label="发起新对话"]',
|
|
||||||
'a[aria-label*="new chat" i]'
|
|
||||||
],
|
|
||||||
modelBtn: [
|
|
||||||
'button:has-text("Gemini")',
|
|
||||||
'[role="button"][aria-haspopup="menu"]'
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ── Debug 日志系统 ── */
|
|
||||||
var _log = [];
|
|
||||||
var _MAX_LOG = 200;
|
|
||||||
|
|
||||||
function _d(fn, step, ok, detail){
|
|
||||||
var entry = {ts:Date.now(), fn:fn, step:step, ok:ok};
|
|
||||||
if(detail!==undefined) entry.detail=detail;
|
|
||||||
_log.push(entry);
|
|
||||||
if(_log.length>_MAX_LOG) _log.splice(0, _log.length-_MAX_LOG);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 取出并清空日志 */
|
|
||||||
function _flush(){
|
|
||||||
var out=_log.slice();
|
|
||||||
_log=[];
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function visible(el){
|
|
||||||
if(!el) return false;
|
|
||||||
const r=el.getBoundingClientRect();
|
|
||||||
const st=getComputedStyle(el);
|
|
||||||
return r.width>0 && r.height>0 && st.display!=='none' && st.visibility!=='hidden';
|
|
||||||
}
|
|
||||||
|
|
||||||
function q(sel){
|
|
||||||
try{
|
|
||||||
if(sel.includes(':has-text(')){
|
|
||||||
const m=sel.match(/^(.*):has-text\("(.*)"\)$/);
|
|
||||||
if(!m) return null;
|
|
||||||
const nodes=[...document.querySelectorAll(m[1]||'*')];
|
|
||||||
return nodes.find(n=>visible(n)&&n.textContent?.includes(m[2]))||null;
|
|
||||||
}
|
|
||||||
return [...document.querySelectorAll(sel)].find(visible)||null;
|
|
||||||
}catch{return null;}
|
|
||||||
}
|
|
||||||
|
|
||||||
function find(key){
|
|
||||||
for(const s of (S[key]||[])){
|
|
||||||
const el=q(s);
|
|
||||||
if(el){
|
|
||||||
_d('find','matched',true,{key:key,selector:s});
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_d('find','no_match',false,{key:key,tried:S[key]||[]});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function click(key){
|
|
||||||
_d('click','start',true,{key:key});
|
|
||||||
const el=find(key);
|
|
||||||
if(!el){
|
|
||||||
_d('click','element_not_found',false,{key:key});
|
|
||||||
return {ok:false,key,error:'not_found',debug:_flush()};
|
|
||||||
}
|
|
||||||
el.click();
|
|
||||||
_d('click','clicked',true,{key:key});
|
|
||||||
return {ok:true,key,debug:_flush()};
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillPrompt(text){
|
|
||||||
_d('fillPrompt','start',true,{textLen:text.length});
|
|
||||||
const el=find('promptInput');
|
|
||||||
if(!el){
|
|
||||||
_d('fillPrompt','input_not_found',false);
|
|
||||||
return {ok:false,error:'prompt_not_found',debug:_flush()};
|
|
||||||
}
|
|
||||||
_d('fillPrompt','input_found',true,{tag:el.tagName});
|
|
||||||
el.focus();
|
|
||||||
if(el.tagName==='TEXTAREA'){
|
|
||||||
el.value=text;
|
|
||||||
el.dispatchEvent(new Event('input',{bubbles:true}));
|
|
||||||
_d('fillPrompt','set_textarea',true);
|
|
||||||
}else{
|
|
||||||
document.execCommand('selectAll',false,null);
|
|
||||||
document.execCommand('insertText',false,text);
|
|
||||||
el.dispatchEvent(new Event('input',{bubbles:true}));
|
|
||||||
_d('fillPrompt','exec_insertText',true);
|
|
||||||
}
|
|
||||||
return {ok:true,debug:_flush()};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatus(){
|
|
||||||
const btn=find('actionBtn');
|
|
||||||
if(!btn){
|
|
||||||
_d('getStatus','btn_not_found',false);
|
|
||||||
return {status:'unknown',error:'btn_not_found'};
|
|
||||||
}
|
|
||||||
const label=(btn.getAttribute('aria-label')||'').trim();
|
|
||||||
const disabled=btn.getAttribute('aria-disabled')==='true';
|
|
||||||
if(/停止|Stop/i.test(label)){
|
|
||||||
_d('getStatus','detected',true,{status:'loading',label:label});
|
|
||||||
return {status:'loading',label};
|
|
||||||
}
|
|
||||||
if(/发送|Send|Submit/i.test(label)){
|
|
||||||
_d('getStatus','detected',true,{status:'ready',label:label,disabled:disabled});
|
|
||||||
return {status:'ready',label,disabled};
|
|
||||||
}
|
|
||||||
_d('getStatus','detected',true,{status:'idle',label:label,disabled:disabled});
|
|
||||||
return {status:'idle',label,disabled};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 保活式轮询 ──
|
|
||||||
* 不在页面内做长 Promise 等待(会导致 CDP 连接因长时间无消息被网关判定空闲断开)。
|
|
||||||
* 改为:调用端每 8-10s evaluate 一次 GeminiOps.pollStatus(),立即拿到结果。
|
|
||||||
* 调用端自行累计耗时并判断超时。
|
|
||||||
*/
|
|
||||||
function pollStatus(){
|
|
||||||
var s=getStatus();
|
|
||||||
_d('pollStatus','polled',true,{status:s.status});
|
|
||||||
return {status:s.status, label:s.label, pageVisible:!document.hidden, ts:Date.now(), debug:_flush()};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 最新图片获取与下载 ──
|
|
||||||
* Gemini 一次只生成一张图片,流程上只关心最新生成的那张。
|
|
||||||
* DOM 中 img.image.loaded 按顺序排列,最后一个即为最新生成。
|
|
||||||
*
|
|
||||||
* DOM 结构:
|
|
||||||
* <div class="image-container ...">
|
|
||||||
* <button class="image-button ...">
|
|
||||||
* <img class="image loaded" src="https://lh3.googleusercontent.com/..." alt="AI 生成">
|
|
||||||
* </button>
|
|
||||||
* <div class="button-icon-wrapper">
|
|
||||||
* <mat-icon fonticon="download" data-mat-icon-name="download" ...></mat-icon>
|
|
||||||
* </div>
|
|
||||||
* </div>
|
|
||||||
*/
|
|
||||||
|
|
||||||
function _findContainer(img){
|
|
||||||
var el=img;
|
|
||||||
while(el&&el!==document.body){
|
|
||||||
if(el.classList&&el.classList.contains('image-container')) return el;
|
|
||||||
el=el.parentElement;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _findDownloadBtn(container){
|
|
||||||
if(!container) return null;
|
|
||||||
return container.querySelector('mat-icon[fonticon="download"]')
|
|
||||||
|| container.querySelector('mat-icon[data-mat-icon-name="download"]')
|
|
||||||
|| null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取最新生成的一张图片信息(DOM 中最后一个 img.image.loaded) */
|
|
||||||
function getLatestImage(){
|
|
||||||
_d('getLatestImage','start',true);
|
|
||||||
var imgs=[...document.querySelectorAll('img.image.loaded')];
|
|
||||||
_d('getLatestImage','query_imgs',true,{totalFound:imgs.length});
|
|
||||||
if(!imgs.length){
|
|
||||||
_d('getLatestImage','no_images',false);
|
|
||||||
return {ok:false, error:'no_loaded_images', debug:_flush()};
|
|
||||||
}
|
|
||||||
var img=imgs[imgs.length-1];
|
|
||||||
_d('getLatestImage','picked_latest',true,{index:imgs.length-1, src:(img.src||'').slice(0,80)});
|
|
||||||
var container=_findContainer(img);
|
|
||||||
_d('getLatestImage','find_container',!!container);
|
|
||||||
var dlBtn=_findDownloadBtn(container);
|
|
||||||
_d('getLatestImage','find_download_btn',!!dlBtn);
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
src: img.src||'',
|
|
||||||
alt: img.alt||'',
|
|
||||||
width: img.naturalWidth||0,
|
|
||||||
height: img.naturalHeight||0,
|
|
||||||
hasDownloadBtn: !!dlBtn,
|
|
||||||
debug: _flush()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 点击最新图片的"下载原图"按钮(仅用户要求高清时调用) */
|
|
||||||
function downloadLatestImage(){
|
|
||||||
_d('downloadLatestImage','start',true);
|
|
||||||
var imgs=[...document.querySelectorAll('img.image.loaded')];
|
|
||||||
_d('downloadLatestImage','query_imgs',true,{totalFound:imgs.length});
|
|
||||||
if(!imgs.length){
|
|
||||||
_d('downloadLatestImage','no_images',false);
|
|
||||||
return {ok:false, error:'no_loaded_images', debug:_flush()};
|
|
||||||
}
|
|
||||||
var img=imgs[imgs.length-1];
|
|
||||||
var container=_findContainer(img);
|
|
||||||
_d('downloadLatestImage','find_container',!!container);
|
|
||||||
var dlBtn=_findDownloadBtn(container);
|
|
||||||
if(!dlBtn){
|
|
||||||
_d('downloadLatestImage','download_btn_not_found',false);
|
|
||||||
return {ok:false, error:'download_btn_not_found', debug:_flush()};
|
|
||||||
}
|
|
||||||
_d('downloadLatestImage','find_download_btn',true);
|
|
||||||
var clickable=dlBtn.closest('button,[role="button"],.button-icon-wrapper')||dlBtn;
|
|
||||||
clickable.click();
|
|
||||||
_d('downloadLatestImage','clicked',true,{clickedTag:clickable.tagName});
|
|
||||||
return {ok:true, src:img.src||'', debug:_flush()};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 图片 Base64 提取 ──
|
|
||||||
* 默认获取图片的方式。直接从已渲染的 DOM 提取,不走网络请求,不触发下载对话框。
|
|
||||||
*
|
|
||||||
* 策略:
|
|
||||||
* 1. Canvas 提取(同步,零网络,最快)
|
|
||||||
* 2. 若 Canvas 被 tainted(跨域污染),fallback 到页面内 fetch → blob → Base64
|
|
||||||
*
|
|
||||||
* 返回 data:image/png;base64,... 格式字符串,调用端直接解码存文件即可。
|
|
||||||
* 注意:fetch fallback 是异步的,因此本函数返回 Promise。
|
|
||||||
* 调用端需用 CDP Runtime.evaluate + awaitPromise:true 来获取结果。
|
|
||||||
*/
|
|
||||||
function extractImageBase64(){
|
|
||||||
_d('extractImageBase64','start',true);
|
|
||||||
var imgs=[...document.querySelectorAll('img.image.loaded')];
|
|
||||||
_d('extractImageBase64','query_imgs',true,{totalFound:imgs.length});
|
|
||||||
if(!imgs.length){
|
|
||||||
_d('extractImageBase64','no_images',false);
|
|
||||||
var dbg=_flush();
|
|
||||||
return Promise.resolve({ok:false, error:'no_loaded_images', debug:dbg});
|
|
||||||
}
|
|
||||||
var img=imgs[imgs.length-1];
|
|
||||||
var w=img.naturalWidth||img.width;
|
|
||||||
var h=img.naturalHeight||img.height;
|
|
||||||
_d('extractImageBase64','picked_latest',true,{index:imgs.length-1, w:w, h:h, src:(img.src||'').slice(0,80)});
|
|
||||||
|
|
||||||
// 尝试 Canvas 同步提取
|
|
||||||
try{
|
|
||||||
var canvas=document.createElement('canvas');
|
|
||||||
canvas.width=w;
|
|
||||||
canvas.height=h;
|
|
||||||
var ctx=canvas.getContext('2d');
|
|
||||||
ctx.drawImage(img,0,0);
|
|
||||||
var dataUrl=canvas.toDataURL('image/png');
|
|
||||||
_d('extractImageBase64','canvas_ok',true,{size:dataUrl.length});
|
|
||||||
var dbg=_flush();
|
|
||||||
return Promise.resolve({ok:true, dataUrl:dataUrl, width:w, height:h, method:'canvas', debug:dbg});
|
|
||||||
}catch(e){
|
|
||||||
_d('extractImageBase64','canvas_tainted',false,{error:e.message||String(e)});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: 页面内 fetch → blob → Base64
|
|
||||||
_d('extractImageBase64','fetch_fallback_start',true,{src:(img.src||'').slice(0,80)});
|
|
||||||
var debugSnapshot=_flush();
|
|
||||||
return fetch(img.src)
|
|
||||||
.then(function(r){
|
|
||||||
if(!r.ok) throw new Error('fetch_status_'+r.status);
|
|
||||||
return r.blob();
|
|
||||||
})
|
|
||||||
.then(function(blob){
|
|
||||||
return new Promise(function(resolve){
|
|
||||||
var reader=new FileReader();
|
|
||||||
reader.onloadend=function(){
|
|
||||||
_d('extractImageBase64','fetch_ok',true,{size:reader.result.length});
|
|
||||||
resolve({ok:true, dataUrl:reader.result, width:w, height:h, method:'fetch', debug:debugSnapshot.concat(_flush())});
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(function(err){
|
|
||||||
_d('extractImageBase64','fetch_failed',false,{error:err.message||String(err)});
|
|
||||||
return {ok:false, error:'extract_failed', detail:err.message||String(err), debug:debugSnapshot.concat(_flush())};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function probe(){
|
|
||||||
_d('probe','start',true);
|
|
||||||
var s=getStatus();
|
|
||||||
var result={
|
|
||||||
promptInput: !!find('promptInput'),
|
|
||||||
actionBtn: !!find('actionBtn'),
|
|
||||||
newChatBtn: !!find('newChatBtn'),
|
|
||||||
modelBtn: !!find('modelBtn'),
|
|
||||||
status: s.status,
|
|
||||||
debug: _flush()
|
|
||||||
};
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取完整调试日志(不清空) */
|
|
||||||
function getDebugLog(){
|
|
||||||
return {log:_log.slice(), count:_log.length};
|
|
||||||
}
|
|
||||||
|
|
||||||
window.GeminiOps = {probe, click, fillPrompt, getStatus, pollStatus, getLatestImage, extractImageBase64, downloadLatestImage, getDebugLog, selectors:S, version:'0.9.0'};
|
|
||||||
})();
|
|
||||||
238
src/browser.js
Normal file
238
src/browser.js
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* browser.js — 浏览器生命周期管理(内部模块,不对外暴露)
|
||||||
|
*
|
||||||
|
* 设计思路:
|
||||||
|
* Skill 内部自己管理 Chrome 进程,对外只暴露 getSession()。
|
||||||
|
* 调用方不需要关心 launch/connect/端口/CDP 等细节。
|
||||||
|
*
|
||||||
|
* 流程:
|
||||||
|
* 1. 先检查指定端口是否已有 Chrome 在跑 → 有就 connect
|
||||||
|
* 2. 没有 → 启动新 Chrome(需要 executablePath)
|
||||||
|
* 3. 找到 / 新开 Gemini 标签页
|
||||||
|
* 4. 返回 { browser, page }
|
||||||
|
*/
|
||||||
|
import puppeteerCore from 'puppeteer-core';
|
||||||
|
import { addExtra } from 'puppeteer-extra';
|
||||||
|
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { createConnection } from 'node:net';
|
||||||
|
|
||||||
|
// ── 用 puppeteer-extra 包装 puppeteer-core,注入 stealth 插件 ──
|
||||||
|
const puppeteer = addExtra(puppeteerCore);
|
||||||
|
puppeteer.use(StealthPlugin());
|
||||||
|
|
||||||
|
// ── 模块级单例:跨调用复用同一个浏览器 ──
|
||||||
|
let _browser = null;
|
||||||
|
|
||||||
|
/** 默认配置 */
|
||||||
|
const DEFAULTS = {
|
||||||
|
port: 9222,
|
||||||
|
userDataDir: join(homedir(), '.gemini-skill', 'chrome-data'),
|
||||||
|
headless: false,
|
||||||
|
protocolTimeout: 60_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 探测指定端口是否有 Chrome 在监听
|
||||||
|
* @param {number} port
|
||||||
|
* @param {string} [host='127.0.0.1']
|
||||||
|
* @param {number} [timeout=1500]
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
function isPortAlive(port, host = '127.0.0.1', timeout = 1500) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = createConnection({ host, port });
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve(false);
|
||||||
|
}, timeout);
|
||||||
|
socket.on('connect', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.destroy();
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
socket.on('error', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chrome 启动参数 */
|
||||||
|
const CHROME_ARGS = [
|
||||||
|
// ── 基础 ──
|
||||||
|
'--no-first-run',
|
||||||
|
'--disable-default-apps',
|
||||||
|
'--disable-popup-blocking',
|
||||||
|
|
||||||
|
// ── 渲染稳定性(无头 / 无显卡服务器) ──
|
||||||
|
'--disable-gpu',
|
||||||
|
'--disable-software-rasterizer',
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
|
||||||
|
// ── 反检测(配合 stealth 插件 + ignoreDefaultArgs) ──
|
||||||
|
'--disable-blink-features=AutomationControlled',
|
||||||
|
|
||||||
|
// ── 网络 / 性能 ──
|
||||||
|
'--disable-background-networking',
|
||||||
|
'--disable-background-timer-throttling',
|
||||||
|
'--disable-backgrounding-occluded-windows',
|
||||||
|
'--disable-renderer-backgrounding',
|
||||||
|
|
||||||
|
// ── UI 纯净度 ──
|
||||||
|
'--disable-features=Translate',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
'--disable-crash-reporter',
|
||||||
|
'--hide-crash-restore-bubble',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接到已运行的 Chrome
|
||||||
|
* @param {number} port
|
||||||
|
* @returns {Promise<import('puppeteer-core').Browser>}
|
||||||
|
*/
|
||||||
|
async function connectToChrome(port) {
|
||||||
|
const browserURL = `http://127.0.0.1:${port}`;
|
||||||
|
const browser = await puppeteer.connect({
|
||||||
|
browserURL,
|
||||||
|
defaultViewport: null,
|
||||||
|
protocolTimeout: DEFAULTS.protocolTimeout,
|
||||||
|
});
|
||||||
|
console.log('[browser] connected to existing Chrome on port', port);
|
||||||
|
return browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动新的 Chrome 实例
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.executablePath
|
||||||
|
* @param {number} opts.port
|
||||||
|
* @param {string} opts.userDataDir
|
||||||
|
* @param {boolean} opts.headless
|
||||||
|
* @returns {Promise<import('puppeteer-core').Browser>}
|
||||||
|
*/
|
||||||
|
async function launchChrome({ executablePath, port, userDataDir, headless }) {
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
executablePath,
|
||||||
|
headless,
|
||||||
|
userDataDir,
|
||||||
|
defaultViewport: null,
|
||||||
|
args: [
|
||||||
|
...CHROME_ARGS,
|
||||||
|
`--remote-debugging-port=${port}`,
|
||||||
|
],
|
||||||
|
ignoreDefaultArgs: ['--enable-automation'],
|
||||||
|
protocolTimeout: DEFAULTS.protocolTimeout,
|
||||||
|
});
|
||||||
|
console.log('[browser] launched Chrome, pid:', browser.process()?.pid, 'port:', port, 'dataDir:', userDataDir);
|
||||||
|
return browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在浏览器中找到 Gemini 标签页,或新开一个
|
||||||
|
* @param {import('puppeteer-core').Browser} browser
|
||||||
|
* @returns {Promise<import('puppeteer-core').Page>}
|
||||||
|
*/
|
||||||
|
async function findOrCreateGeminiPage(browser) {
|
||||||
|
const pages = await browser.pages();
|
||||||
|
|
||||||
|
// 优先复用已有的 Gemini 标签页
|
||||||
|
for (const page of pages) {
|
||||||
|
const url = page.url();
|
||||||
|
if (url.includes('gemini.google.com')) {
|
||||||
|
console.log('[browser] reusing existing Gemini tab:', url);
|
||||||
|
await page.bringToFront();
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没找到,新开一个
|
||||||
|
const page = pages.length > 0 ? pages[0] : await browser.newPage();
|
||||||
|
await page.goto('https://gemini.google.com/app', {
|
||||||
|
waitUntil: 'networkidle2',
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
console.log('[browser] opened new Gemini tab');
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保浏览器可用 — Skill 唯一的对外浏览器管理入口
|
||||||
|
*
|
||||||
|
* 逻辑:
|
||||||
|
* 1. 如果已有 _browser 且未断开 → 直接复用
|
||||||
|
* 2. 检查端口是否有 Chrome → connect
|
||||||
|
* 3. 否则 launch 新 Chrome(需要 executablePath)
|
||||||
|
*
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {string} [opts.executablePath] - Chrome 路径(仅 launch 时需要)
|
||||||
|
* @param {number} [opts.port=9222] - 调试端口
|
||||||
|
* @param {string} [opts.userDataDir] - 用户数据目录
|
||||||
|
* @param {boolean} [opts.headless=false]
|
||||||
|
* @returns {Promise<{browser: import('puppeteer-core').Browser, page: import('puppeteer-core').Page}>}
|
||||||
|
*/
|
||||||
|
export async function ensureBrowser(opts = {}) {
|
||||||
|
const {
|
||||||
|
executablePath,
|
||||||
|
port = DEFAULTS.port,
|
||||||
|
userDataDir = DEFAULTS.userDataDir,
|
||||||
|
headless = DEFAULTS.headless,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
// 1. 复用已有连接
|
||||||
|
if (_browser && _browser.isConnected()) {
|
||||||
|
console.log('[browser] reusing existing connection');
|
||||||
|
const page = await findOrCreateGeminiPage(_browser);
|
||||||
|
return { browser: _browser, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 尝试连接已在运行的 Chrome
|
||||||
|
const alive = await isPortAlive(port);
|
||||||
|
if (alive) {
|
||||||
|
try {
|
||||||
|
_browser = await connectToChrome(port);
|
||||||
|
const page = await findOrCreateGeminiPage(_browser);
|
||||||
|
return { browser: _browser, page };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[browser] connect failed, will try launch:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 启动新 Chrome
|
||||||
|
if (!executablePath) {
|
||||||
|
throw new Error(
|
||||||
|
`[browser] 端口 ${port} 无可用 Chrome,且未提供 executablePath。\n` +
|
||||||
|
`请先手动启动 Chrome:chrome --remote-debugging-port=${port} --user-data-dir="${userDataDir}"\n` +
|
||||||
|
`或传入 executablePath 让 skill 自动启动。`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_browser = await launchChrome({ executablePath, port, userDataDir, headless });
|
||||||
|
const page = await findOrCreateGeminiPage(_browser);
|
||||||
|
return { browser: _browser, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断开浏览器连接(不杀 Chrome 进程,方便下次复用)
|
||||||
|
*/
|
||||||
|
export function disconnect() {
|
||||||
|
if (_browser) {
|
||||||
|
_browser.disconnect();
|
||||||
|
_browser = null;
|
||||||
|
console.log('[browser] disconnected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭浏览器(杀 Chrome 进程)
|
||||||
|
*/
|
||||||
|
export async function close() {
|
||||||
|
if (_browser) {
|
||||||
|
await _browser.close();
|
||||||
|
_browser = null;
|
||||||
|
console.log('[browser] closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/demo.js
Normal file
47
src/demo.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* demo.js — 使用示例
|
||||||
|
*
|
||||||
|
* 两种启动方式:
|
||||||
|
*
|
||||||
|
* 方式 1(推荐):先手动启动 Chrome,再运行 demo
|
||||||
|
* chrome --remote-debugging-port=9222 --user-data-dir="~/.gemini-skill/chrome-data"
|
||||||
|
* node src/demo.js
|
||||||
|
*
|
||||||
|
* 方式 2:让 skill 自动启动 Chrome
|
||||||
|
* CHROME_PATH="C:/Program Files/Google/Chrome/Application/chrome.exe" node src/demo.js
|
||||||
|
*/
|
||||||
|
import { createGeminiSession, disconnect } from './index.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('=== Gemini Skill Demo ===\n');
|
||||||
|
|
||||||
|
// 创建会话(自动 connect 或 launch)
|
||||||
|
const { ops } = await createGeminiSession({
|
||||||
|
executablePath: process.env.CHROME_PATH || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 探测页面状态
|
||||||
|
console.log('[1] 探测页面元素...');
|
||||||
|
const probe = await ops.probe();
|
||||||
|
console.log('probe:', JSON.stringify(probe, null, 2));
|
||||||
|
|
||||||
|
// 2. 发送一句话
|
||||||
|
console.log('\n[2] 发送提示词...');
|
||||||
|
const result = await ops.sendAndWait('Hello Gemini!', {
|
||||||
|
timeout: 60_000,
|
||||||
|
onPoll(poll) {
|
||||||
|
console.log(` polling... status=${poll.status}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('result:', JSON.stringify(result, null, 2));
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error:', err);
|
||||||
|
} finally {
|
||||||
|
disconnect();
|
||||||
|
console.log('\n[done]');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
350
src/gemini-ops.js
Normal file
350
src/gemini-ops.js
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
/**
|
||||||
|
* gemini-ops.js — Gemini 操作高层 API
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 基于 operator.js 的底层原子操作,编排 Gemini 特定的业务流程。
|
||||||
|
* 全部通过 CDP 实现,不往页面注入任何对象。
|
||||||
|
*/
|
||||||
|
import { createOperator } from './operator.js';
|
||||||
|
|
||||||
|
// ── Gemini 页面元素选择器 ──
|
||||||
|
const SELECTORS = {
|
||||||
|
promptInput: [
|
||||||
|
'div.ql-editor[contenteditable="true"][role="textbox"]',
|
||||||
|
'[contenteditable="true"][aria-label*="Gemini"]',
|
||||||
|
'[contenteditable="true"][data-placeholder*="Gemini"]',
|
||||||
|
'div[contenteditable="true"][role="textbox"]',
|
||||||
|
],
|
||||||
|
actionBtn: [
|
||||||
|
'.send-button-container button.send-button',
|
||||||
|
'.send-button-container button',
|
||||||
|
],
|
||||||
|
newChatBtn: [
|
||||||
|
'[data-test-id="new-chat-button"] a',
|
||||||
|
'[data-test-id="new-chat-button"]',
|
||||||
|
'a[aria-label="发起新对话"]',
|
||||||
|
'a[aria-label*="new chat" i]',
|
||||||
|
],
|
||||||
|
modelBtn: [
|
||||||
|
'button:has-text("Gemini")',
|
||||||
|
'[role="button"][aria-haspopup="menu"]',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 GeminiOps 操控实例
|
||||||
|
* @param {import('puppeteer-core').Page} page
|
||||||
|
*/
|
||||||
|
export function createOps(page) {
|
||||||
|
const op = createOperator(page);
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** 暴露底层 operator,供高级用户直接使用 */
|
||||||
|
operator: op,
|
||||||
|
|
||||||
|
/** 暴露选择器定义,方便调试和外部扩展 */
|
||||||
|
selectors: SELECTORS,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 探测页面各元素是否就位
|
||||||
|
* @returns {Promise<{promptInput: boolean, actionBtn: boolean, newChatBtn: boolean, modelBtn: boolean, status: object}>}
|
||||||
|
*/
|
||||||
|
async probe() {
|
||||||
|
const [promptInput, actionBtn, newChatBtn, modelBtn] = await Promise.all([
|
||||||
|
op.locate(SELECTORS.promptInput),
|
||||||
|
op.locate(SELECTORS.actionBtn),
|
||||||
|
op.locate(SELECTORS.newChatBtn),
|
||||||
|
op.locate(SELECTORS.modelBtn),
|
||||||
|
]);
|
||||||
|
const status = await this.getStatus();
|
||||||
|
return {
|
||||||
|
promptInput: promptInput.found,
|
||||||
|
actionBtn: actionBtn.found,
|
||||||
|
newChatBtn: newChatBtn.found,
|
||||||
|
modelBtn: modelBtn.found,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击指定按钮
|
||||||
|
* @param {'actionBtn'|'newChatBtn'|'modelBtn'} key
|
||||||
|
*/
|
||||||
|
async click(key) {
|
||||||
|
const sels = SELECTORS[key];
|
||||||
|
if (!sels) {
|
||||||
|
return { ok: false, error: `unknown_key: ${key}` };
|
||||||
|
}
|
||||||
|
return op.click(sels);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 填写提示词(快速填充,非逐字输入)
|
||||||
|
* @param {string} text
|
||||||
|
*/
|
||||||
|
async fillPrompt(text) {
|
||||||
|
return op.fill(SELECTORS.promptInput, text);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前按钮状态(通过一次性 evaluate 读取,不注入任何东西)
|
||||||
|
*/
|
||||||
|
async getStatus() {
|
||||||
|
return op.query((sels) => {
|
||||||
|
// 在页面上下文中查找 actionBtn
|
||||||
|
let btn = null;
|
||||||
|
for (const sel of sels) {
|
||||||
|
try {
|
||||||
|
const all = [...document.querySelectorAll(sel)];
|
||||||
|
btn = all.find(n => {
|
||||||
|
const r = n.getBoundingClientRect();
|
||||||
|
const st = getComputedStyle(n);
|
||||||
|
return r.width > 0 && r.height > 0
|
||||||
|
&& st.display !== 'none' && st.visibility !== 'hidden';
|
||||||
|
}) || null;
|
||||||
|
} catch { /* skip */ }
|
||||||
|
if (btn) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!btn) return { status: 'unknown', error: 'btn_not_found' };
|
||||||
|
|
||||||
|
const label = (btn.getAttribute('aria-label') || '').trim();
|
||||||
|
const disabled = btn.getAttribute('aria-disabled') === 'true';
|
||||||
|
|
||||||
|
if (/停止|Stop/i.test(label)) {
|
||||||
|
return { status: 'loading', label };
|
||||||
|
}
|
||||||
|
if (/发送|Send|Submit/i.test(label)) {
|
||||||
|
return { status: 'ready', label, disabled };
|
||||||
|
}
|
||||||
|
return { status: 'idle', label, disabled };
|
||||||
|
}, SELECTORS.actionBtn);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单次轮询状态(保活式,不阻塞)
|
||||||
|
*/
|
||||||
|
async pollStatus() {
|
||||||
|
const status = await this.getStatus();
|
||||||
|
const pageVisible = await op.query(() => !document.hidden);
|
||||||
|
return { ...status, pageVisible, ts: Date.now() };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最新生成的图片信息
|
||||||
|
*/
|
||||||
|
async getLatestImage() {
|
||||||
|
return op.query(() => {
|
||||||
|
const imgs = [...document.querySelectorAll('img.image.loaded')];
|
||||||
|
if (!imgs.length) {
|
||||||
|
return { ok: false, error: 'no_loaded_images' };
|
||||||
|
}
|
||||||
|
const img = imgs[imgs.length - 1];
|
||||||
|
// 查找下载按钮
|
||||||
|
let container = img;
|
||||||
|
while (container && container !== document.body) {
|
||||||
|
if (container.classList?.contains('image-container')) break;
|
||||||
|
container = container.parentElement;
|
||||||
|
}
|
||||||
|
const dlBtn = container
|
||||||
|
? (container.querySelector('mat-icon[fonticon="download"]')
|
||||||
|
|| container.querySelector('mat-icon[data-mat-icon-name="download"]'))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
src: img.src || '',
|
||||||
|
alt: img.alt || '',
|
||||||
|
width: img.naturalWidth || 0,
|
||||||
|
height: img.naturalHeight || 0,
|
||||||
|
hasDownloadBtn: !!dlBtn,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取最新图片的 Base64 数据(Canvas 优先,fetch 兜底)
|
||||||
|
*/
|
||||||
|
async extractImageBase64() {
|
||||||
|
return op.query(() => {
|
||||||
|
const imgs = [...document.querySelectorAll('img.image.loaded')];
|
||||||
|
if (!imgs.length) {
|
||||||
|
return { ok: false, error: 'no_loaded_images' };
|
||||||
|
}
|
||||||
|
const img = imgs[imgs.length - 1];
|
||||||
|
const w = img.naturalWidth || img.width;
|
||||||
|
const h = img.naturalHeight || img.height;
|
||||||
|
|
||||||
|
// 尝试 Canvas 同步提取
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
canvas.getContext('2d').drawImage(img, 0, 0);
|
||||||
|
const dataUrl = canvas.toDataURL('image/png');
|
||||||
|
return { ok: true, dataUrl, width: w, height: h, method: 'canvas' };
|
||||||
|
} catch { /* canvas tainted, fallback */ }
|
||||||
|
|
||||||
|
// 标记需要 fetch fallback
|
||||||
|
return { ok: false, needFetch: true, src: img.src, width: w, height: h };
|
||||||
|
}).then(async (result) => {
|
||||||
|
if (result.ok || !result.needFetch) return result;
|
||||||
|
|
||||||
|
// Fetch fallback: 在页面上下文中异步执行
|
||||||
|
return page.evaluate(async (src, w, h) => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(src);
|
||||||
|
if (!r.ok) throw new Error(`fetch_status_${r.status}`);
|
||||||
|
const blob = await r.blob();
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => resolve({
|
||||||
|
ok: true, dataUrl: reader.result, width: w, height: h, method: 'fetch',
|
||||||
|
});
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: 'extract_failed', detail: err.message || String(err) };
|
||||||
|
}
|
||||||
|
}, result.src, result.width, result.height);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击最新图片的下载按钮
|
||||||
|
*/
|
||||||
|
async downloadLatestImage() {
|
||||||
|
return op.query(() => {
|
||||||
|
const imgs = [...document.querySelectorAll('img.image.loaded')];
|
||||||
|
if (!imgs.length) return { ok: false, error: 'no_loaded_images' };
|
||||||
|
|
||||||
|
const img = imgs[imgs.length - 1];
|
||||||
|
let container = img;
|
||||||
|
while (container && container !== document.body) {
|
||||||
|
if (container.classList?.contains('image-container')) break;
|
||||||
|
container = container.parentElement;
|
||||||
|
}
|
||||||
|
const dlBtn = container
|
||||||
|
? (container.querySelector('mat-icon[fonticon="download"]')
|
||||||
|
|| container.querySelector('mat-icon[data-mat-icon-name="download"]'))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!dlBtn) return { ok: false, error: 'download_btn_not_found' };
|
||||||
|
|
||||||
|
const clickable = dlBtn.closest('button,[role="button"],.button-icon-wrapper') || dlBtn;
|
||||||
|
clickable.click();
|
||||||
|
return { ok: true, src: img.src || '' };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── 高层组合操作 ───
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送提示词并等待生成完成
|
||||||
|
* @param {string} prompt
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {number} [opts.timeout=120000]
|
||||||
|
* @param {number} [opts.interval=8000]
|
||||||
|
* @param {(status: object) => void} [opts.onPoll]
|
||||||
|
* @returns {Promise<{ok: boolean, elapsed: number, finalStatus?: object, error?: string}>}
|
||||||
|
*/
|
||||||
|
async sendAndWait(prompt, opts = {}) {
|
||||||
|
const { timeout = 120_000, interval = 8_000, onPoll } = opts;
|
||||||
|
|
||||||
|
// 1. 填写
|
||||||
|
const fillResult = await this.fillPrompt(prompt);
|
||||||
|
if (!fillResult.ok) {
|
||||||
|
return { ok: false, error: 'fill_failed', detail: fillResult, elapsed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 短暂等待 UI 响应
|
||||||
|
await sleep(300);
|
||||||
|
|
||||||
|
// 2. 点击发送
|
||||||
|
const clickResult = await this.click('actionBtn');
|
||||||
|
if (!clickResult.ok) {
|
||||||
|
return { ok: false, error: 'send_click_failed', detail: clickResult, elapsed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 轮询等待
|
||||||
|
const start = Date.now();
|
||||||
|
let lastStatus = null;
|
||||||
|
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
await sleep(interval);
|
||||||
|
|
||||||
|
const poll = await this.pollStatus();
|
||||||
|
lastStatus = poll;
|
||||||
|
onPoll?.(poll);
|
||||||
|
|
||||||
|
if (poll.status === 'idle') {
|
||||||
|
return { ok: true, elapsed: Date.now() - start, finalStatus: poll };
|
||||||
|
}
|
||||||
|
if (poll.status === 'unknown') {
|
||||||
|
console.warn('[ops] unknown status, may need screenshot to debug');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, error: 'timeout', elapsed: Date.now() - start, finalStatus: lastStatus };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整生图流程:新建会话 → 发送提示词 → 等待 → 提取图片
|
||||||
|
* @param {string} prompt
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {number} [opts.timeout=120000]
|
||||||
|
* @param {boolean} [opts.newChat=true]
|
||||||
|
* @param {boolean} [opts.highRes=false]
|
||||||
|
* @param {(status: object) => void} [opts.onPoll]
|
||||||
|
*/
|
||||||
|
async generateImage(prompt, opts = {}) {
|
||||||
|
const { timeout = 120_000, newChat = true, highRes = false, onPoll } = opts;
|
||||||
|
|
||||||
|
// 1. 可选:新建会话
|
||||||
|
if (newChat) {
|
||||||
|
const newChatResult = await this.click('newChatBtn');
|
||||||
|
if (!newChatResult.ok) {
|
||||||
|
console.warn('[ops] newChatBtn click failed, continuing anyway');
|
||||||
|
}
|
||||||
|
await sleep(1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 发送并等待
|
||||||
|
const waitResult = await this.sendAndWait(prompt, { timeout, onPoll });
|
||||||
|
if (!waitResult.ok) {
|
||||||
|
return { ...waitResult, step: 'sendAndWait' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 等图片渲染完成
|
||||||
|
await sleep(2000);
|
||||||
|
|
||||||
|
// 4. 获取图片
|
||||||
|
const imgInfo = await this.getLatestImage();
|
||||||
|
if (!imgInfo.ok) {
|
||||||
|
await sleep(3000);
|
||||||
|
const retry = await this.getLatestImage();
|
||||||
|
if (!retry.ok) {
|
||||||
|
return { ok: false, error: 'no_image_found', elapsed: waitResult.elapsed, imgInfo: retry };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 提取 / 下载
|
||||||
|
if (highRes) {
|
||||||
|
const dlResult = await this.downloadLatestImage();
|
||||||
|
return { ok: dlResult.ok, method: 'download', elapsed: waitResult.elapsed, ...dlResult };
|
||||||
|
} else {
|
||||||
|
const b64Result = await this.extractImageBase64();
|
||||||
|
return { ok: b64Result.ok, method: b64Result.method, elapsed: waitResult.elapsed, ...b64Result };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 底层 page 引用 */
|
||||||
|
get page() {
|
||||||
|
return page;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(r => setTimeout(r, ms));
|
||||||
|
}
|
||||||
37
src/index.js
Normal file
37
src/index.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* gemini-skill — 统一入口
|
||||||
|
*
|
||||||
|
* 对外只暴露高层 API,浏览器管理在内部自动完成。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* import { createGeminiSession, disconnect } from './index.js';
|
||||||
|
*
|
||||||
|
* const { ops } = await createGeminiSession();
|
||||||
|
* await ops.generateImage('画一只猫');
|
||||||
|
* disconnect();
|
||||||
|
*/
|
||||||
|
import { ensureBrowser, disconnect, close } from './browser.js';
|
||||||
|
import { createOps } from './gemini-ops.js';
|
||||||
|
|
||||||
|
export { disconnect, close };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Gemini 操控会话
|
||||||
|
*
|
||||||
|
* 内部自动管理浏览器连接:
|
||||||
|
* 1. 端口有 Chrome → 直接 connect
|
||||||
|
* 2. 无 Chrome + 提供了 executablePath → 自动 launch
|
||||||
|
* 3. 无 Chrome + 无 executablePath → 报错并提示手动启动
|
||||||
|
*
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {string} [opts.executablePath] - Chrome 路径(可选,仅自动启动时需要)
|
||||||
|
* @param {number} [opts.port=9222] - 调试端口
|
||||||
|
* @param {string} [opts.userDataDir] - 用户数据目录(默认 ~/.gemini-skill/chrome-data)
|
||||||
|
* @param {boolean} [opts.headless=false]
|
||||||
|
* @returns {Promise<{ops: ReturnType<typeof createOps>, page: import('puppeteer-core').Page, browser: import('puppeteer-core').Browser}>}
|
||||||
|
*/
|
||||||
|
export async function createGeminiSession(opts = {}) {
|
||||||
|
const { browser, page } = await ensureBrowser(opts);
|
||||||
|
const ops = createOps(page);
|
||||||
|
return { ops, page, browser };
|
||||||
|
}
|
||||||
326
src/operator.js
Normal file
326
src/operator.js
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
/**
|
||||||
|
* operator.js — 纯 CDP 底层操作封装
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 封装最基础的浏览器交互原语(点击、输入、查询、等待等),
|
||||||
|
* 全部通过 CDP 协议实现,不往页面注入任何对象。
|
||||||
|
*
|
||||||
|
* 设计原则:
|
||||||
|
* - 所有 DOM 操作通过 page.evaluate() 一次性执行,执行完即走,不留痕迹
|
||||||
|
* - 鼠标 / 键盘事件通过 CDP Input 域发送,生成 isTrusted=true 的原生事件
|
||||||
|
* - 每个方法都是独立的原子操作,上层 gemini-ops.js 负责编排组合
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 operator 实例
|
||||||
|
* @param {import('puppeteer-core').Page} page
|
||||||
|
*/
|
||||||
|
export function createOperator(page) {
|
||||||
|
|
||||||
|
// ─── 内部工具 ───
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 CSS 选择器列表查找第一个可见元素,返回其中心坐标和边界信息
|
||||||
|
* 在页面上下文中执行,执行完即走
|
||||||
|
* @param {string[]} selectors - 候选选择器,按优先级排列
|
||||||
|
* @returns {Promise<{found: boolean, x?: number, y?: number, width?: number, height?: number, selector?: string, tagName?: string}>}
|
||||||
|
*/
|
||||||
|
async function locate(selectors) {
|
||||||
|
return page.evaluate((sels) => {
|
||||||
|
for (const sel of sels) {
|
||||||
|
let el = null;
|
||||||
|
try {
|
||||||
|
// 支持 :has-text("xxx") 伪选择器
|
||||||
|
if (sel.includes(':has-text(')) {
|
||||||
|
const m = sel.match(/^(.*):has-text\("(.*)"\)$/);
|
||||||
|
if (m) {
|
||||||
|
const candidates = [...document.querySelectorAll(m[1] || '*')];
|
||||||
|
el = candidates.find(n => {
|
||||||
|
const r = n.getBoundingClientRect();
|
||||||
|
const st = getComputedStyle(n);
|
||||||
|
return r.width > 0 && r.height > 0
|
||||||
|
&& st.display !== 'none' && st.visibility !== 'hidden'
|
||||||
|
&& n.textContent?.includes(m[2]);
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const all = [...document.querySelectorAll(sel)];
|
||||||
|
el = all.find(n => {
|
||||||
|
const r = n.getBoundingClientRect();
|
||||||
|
const st = getComputedStyle(n);
|
||||||
|
return r.width > 0 && r.height > 0
|
||||||
|
&& st.display !== 'none' && st.visibility !== 'hidden';
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
} catch { /* 选择器语法错误,跳过 */ }
|
||||||
|
|
||||||
|
if (el) {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
x: rect.x + rect.width / 2,
|
||||||
|
y: rect.y + rect.height / 2,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
selector: sel,
|
||||||
|
tagName: el.tagName.toLowerCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { found: false };
|
||||||
|
}, selectors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 给坐标加一点随机偏移,模拟人类鼠标不精确的特征
|
||||||
|
* @param {number} x
|
||||||
|
* @param {number} y
|
||||||
|
* @param {number} [jitter=3] - 最大偏移像素
|
||||||
|
* @returns {{x: number, y: number}}
|
||||||
|
*/
|
||||||
|
function humanize(x, y, jitter = 3) {
|
||||||
|
return {
|
||||||
|
x: x + (Math.random() * 2 - 1) * jitter,
|
||||||
|
y: y + (Math.random() * 2 - 1) * jitter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 随机延迟(毫秒),模拟人类反应时间
|
||||||
|
* @param {number} min
|
||||||
|
* @param {number} max
|
||||||
|
*/
|
||||||
|
function randomDelay(min, max) {
|
||||||
|
const ms = min + Math.random() * (max - min);
|
||||||
|
return new Promise(r => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 公开 API ───
|
||||||
|
|
||||||
|
return {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定位元素 — 通过选择器列表查找第一个可见元素
|
||||||
|
* @param {string|string[]} selectors - 单个选择器或候选列表
|
||||||
|
* @returns {Promise<{found: boolean, x?: number, y?: number, width?: number, height?: number, selector?: string, tagName?: string}>}
|
||||||
|
*/
|
||||||
|
async locate(selectors) {
|
||||||
|
const sels = Array.isArray(selectors) ? selectors : [selectors];
|
||||||
|
return locate(sels);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击元素 — 通过 CDP Input.dispatchMouseEvent 发送真实鼠标事件
|
||||||
|
*
|
||||||
|
* 生成 isTrusted=true 的原生事件,比 element.click() 更真实
|
||||||
|
*
|
||||||
|
* @param {string|string[]} selectors - 候选选择器
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {number} [opts.jitter=3] - 坐标随机偏移像素
|
||||||
|
* @param {number} [opts.delayBeforeClick=50] - 移动到元素后、点击前的等待(ms)
|
||||||
|
* @param {number} [opts.clickDuration=80] - mousedown 到 mouseup 的间隔(ms)
|
||||||
|
* @returns {Promise<{ok: boolean, selector?: string, x?: number, y?: number, error?: string}>}
|
||||||
|
*/
|
||||||
|
async click(selectors, opts = {}) {
|
||||||
|
const { jitter = 3, delayBeforeClick = 50, clickDuration = 80 } = opts;
|
||||||
|
|
||||||
|
const sels = Array.isArray(selectors) ? selectors : [selectors];
|
||||||
|
const loc = await locate(sels);
|
||||||
|
if (!loc.found) {
|
||||||
|
return { ok: false, error: 'element_not_found', triedSelectors: sels };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { x, y } = humanize(loc.x, loc.y, jitter);
|
||||||
|
|
||||||
|
// 先移动鼠标到目标位置
|
||||||
|
await page.mouse.move(x, y);
|
||||||
|
await randomDelay(delayBeforeClick * 0.5, delayBeforeClick * 1.5);
|
||||||
|
|
||||||
|
// mousedown → 短暂停留 → mouseup(模拟真实点击节奏)
|
||||||
|
await page.mouse.down();
|
||||||
|
await randomDelay(clickDuration * 0.5, clickDuration * 1.5);
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
return { ok: true, selector: loc.selector, x, y };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输入文本 — 支持两种模式
|
||||||
|
*
|
||||||
|
* - `'paste'`(默认):通过剪贴板粘贴,整段文本一次性输入,人类也经常这样操作
|
||||||
|
* - `'typeChar'`:逐字符键盘输入,每个字符间有随机延迟,模拟打字节奏
|
||||||
|
*
|
||||||
|
* @param {string} text - 要输入的文本
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {'paste'|'typeChar'} [opts.mode='paste'] - 输入模式
|
||||||
|
* @param {number} [opts.minDelay=30] - typeChar 模式下字符间最小间隔(ms)
|
||||||
|
* @param {number} [opts.maxDelay=80] - typeChar 模式下字符间最大间隔(ms)
|
||||||
|
* @returns {Promise<{ok: boolean, length: number, mode: string}>}
|
||||||
|
*/
|
||||||
|
async type(text, opts = {}) {
|
||||||
|
const { mode = 'paste', minDelay = 30, maxDelay = 80 } = opts;
|
||||||
|
|
||||||
|
if (mode === 'typeChar') {
|
||||||
|
// 逐字符输入,模拟真实打字
|
||||||
|
for (const char of text) {
|
||||||
|
await page.keyboard.type(char);
|
||||||
|
await randomDelay(minDelay, maxDelay);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 粘贴模式:通过 CDP Input.insertText 一次性输入整段文本
|
||||||
|
// 等价于用户从剪贴板粘贴,但不依赖 clipboard API 权限
|
||||||
|
const client = page._client();
|
||||||
|
await client.send('Input.insertText', { text });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, length: text.length, mode };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快速设置文本 — 对 contenteditable 元素,用 Ctrl+A → 粘贴的方式填充
|
||||||
|
*
|
||||||
|
* 比逐字输入快得多,适合长文本(如 prompt)
|
||||||
|
* 同样不注入任何对象,通过 evaluate 执行一次性 DOM 操作
|
||||||
|
*
|
||||||
|
* @param {string|string[]} selectors - 目标输入框选择器
|
||||||
|
* @param {string} text - 要填入的文本
|
||||||
|
* @returns {Promise<{ok: boolean, selector?: string, error?: string}>}
|
||||||
|
*/
|
||||||
|
async fill(selectors, text) {
|
||||||
|
const sels = Array.isArray(selectors) ? selectors : [selectors];
|
||||||
|
const loc = await locate(sels);
|
||||||
|
if (!loc.found) {
|
||||||
|
return { ok: false, error: 'element_not_found', triedSelectors: sels };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先点击聚焦目标元素
|
||||||
|
const { x, y } = humanize(loc.x, loc.y, 2);
|
||||||
|
await page.mouse.click(x, y);
|
||||||
|
await randomDelay(100, 200);
|
||||||
|
|
||||||
|
// 在页面上下文中执行文本填充(一次性,不留痕迹)
|
||||||
|
const result = await page.evaluate((selsInner, textInner) => {
|
||||||
|
// 重新查找元素(因为 click 后 DOM 可能有变化)
|
||||||
|
let el = null;
|
||||||
|
for (const sel of selsInner) {
|
||||||
|
try {
|
||||||
|
const all = [...document.querySelectorAll(sel)];
|
||||||
|
el = all.find(n => {
|
||||||
|
const r = n.getBoundingClientRect();
|
||||||
|
return r.width > 0 && r.height > 0;
|
||||||
|
}) || null;
|
||||||
|
} catch { /* skip */ }
|
||||||
|
if (el) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!el) return { ok: false, error: 'element_lost_after_click' };
|
||||||
|
|
||||||
|
el.focus();
|
||||||
|
|
||||||
|
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
|
||||||
|
// 原生表单元素
|
||||||
|
el.value = textInner;
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
} else {
|
||||||
|
// contenteditable 元素(如 Gemini 的富文本输入框)
|
||||||
|
document.execCommand('selectAll', false, null);
|
||||||
|
document.execCommand('insertText', false, textInner);
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}, sels, text);
|
||||||
|
|
||||||
|
return { ...result, selector: loc.selector };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在页面上下文中执行一次性查询(不注入任何对象)
|
||||||
|
*
|
||||||
|
* @param {((...args: any[]) => any)} fn - 要在页面中执行的函数
|
||||||
|
* @param {...any} args - 传入函数的参数
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
async query(fn, ...args) {
|
||||||
|
return page.evaluate(fn, ...args);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等待某个条件满足(轮询式)
|
||||||
|
*
|
||||||
|
* @param {((...args: any[]) => any)} conditionFn - 在页面中执行的判断函数,返回 truthy 值表示满足
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {number} [opts.timeout=30000] - 最大等待时间(ms)
|
||||||
|
* @param {number} [opts.interval=500] - 轮询间隔(ms)
|
||||||
|
* @param {any[]} [opts.args=[]] - 传入 conditionFn 的参数
|
||||||
|
* @returns {Promise<{ok: boolean, result?: any, elapsed: number, error?: string}>}
|
||||||
|
*/
|
||||||
|
async waitFor(conditionFn, opts = {}) {
|
||||||
|
const { timeout = 30_000, interval = 500, args = [] } = opts;
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
try {
|
||||||
|
const result = await page.evaluate(conditionFn, ...args);
|
||||||
|
if (result) {
|
||||||
|
return { ok: true, result, elapsed: Date.now() - start };
|
||||||
|
}
|
||||||
|
} catch { /* 页面可能还在加载 */ }
|
||||||
|
await new Promise(r => setTimeout(r, interval));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, error: 'timeout', elapsed: Date.now() - start };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等待导航完成
|
||||||
|
*
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {string} [opts.waitUntil='networkidle2']
|
||||||
|
* @param {number} [opts.timeout=30000]
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async waitForNavigation(opts = {}) {
|
||||||
|
const { waitUntil = 'networkidle2', timeout = 30_000 } = opts;
|
||||||
|
await page.waitForNavigation({ waitUntil, timeout });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按下键盘快捷键
|
||||||
|
*
|
||||||
|
* @param {string} key - 键名(如 'Enter'、'Tab'、'Escape')
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {number} [opts.delay=50] - keydown 到 keyup 的间隔
|
||||||
|
* @returns {Promise<{ok: boolean, key: string}>}
|
||||||
|
*/
|
||||||
|
async press(key, opts = {}) {
|
||||||
|
const { delay = 50 } = opts;
|
||||||
|
await page.keyboard.press(key, { delay });
|
||||||
|
return { ok: true, key };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面截图(用于调试或状态验证)
|
||||||
|
*
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {boolean} [opts.fullPage=false]
|
||||||
|
* @param {'png'|'jpeg'|'webp'} [opts.type='png']
|
||||||
|
* @param {string} [opts.path] - 保存路径(不传则返回 Buffer)
|
||||||
|
* @returns {Promise<Buffer>}
|
||||||
|
*/
|
||||||
|
async screenshot(opts = {}) {
|
||||||
|
return page.screenshot(opts);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取页面当前 URL
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
url() {
|
||||||
|
return page.url();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 底层 page 对象引用 */
|
||||||
|
get page() {
|
||||||
|
return page;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user