Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
609 changes: 609 additions & 0 deletions README-en.md

Large diffs are not rendered by default.

608 changes: 0 additions & 608 deletions README-zh.md

This file was deleted.

464 changes: 283 additions & 181 deletions README.md

Large diffs are not rendered by default.

7,115 changes: 1,728 additions & 5,387 deletions package-lock.json

Large diffs are not rendered by default.

25 changes: 13 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,23 @@
"test:both": "set MODE=both && node build/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.2",
"@modelcontextprotocol/sdk": "^1.27.1",
"@types/axios": "^0.14.4",
"@types/cheerio": "^0.22.35",
"axios": "^1.7.9",
"cheerio": "^1.0.0",
"cors": "^2.8.5",
"express": "^4.18.2",
"@types/cheerio": "^1.0.0",
"axios": "^1.13.5",
"cheerio": "^1.2.0",
"cors": "^2.8.6",
"express": "^5.2.1",
"https-proxy-agent": "^7.0.6",
"jsdom": "^26.1.0",
"npx": "^10.2.2"
"jsdom": "^28.1.0",
"puppeteer-core": "^24.37.5",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^4.17.21",
"@types/jsdom": "^21.1.7",
"@types/node": "^20.17.10",
"typescript": "^5.3.3"
"@types/express": "^5.0.6",
"@types/jsdom": "^28.0.0",
"@types/node": "^25.3.1",
"typescript": "^5.9.3"
}
}
10 changes: 9 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export interface AppConfig {
corsOrigin: string;
// Server configuration (determined by MODE env var: 'both', 'http', or 'stdio')
enableHttpServer: boolean;
// 搜索结果描述最大长度(undefined = 不限制)
maxDescriptionLength?: number;
// 网络请求超时时间(毫秒)
requestTimeout: number;
}

// Read from environment variables or use defaults
Expand All @@ -30,7 +34,11 @@ export const config: AppConfig = {
corsOrigin: process.env.CORS_ORIGIN || '*',
// Server configuration - determined by MODE environment variable
// Modes: 'both' (default), 'http', 'stdio'
enableHttpServer: process.env.MODE ? ['both', 'http'].includes(process.env.MODE) : true
enableHttpServer: process.env.MODE ? ['both', 'http'].includes(process.env.MODE) : true,
// 搜索结果描述最大长度
maxDescriptionLength: process.env.MAX_DESCRIPTION_LENGTH ? parseInt(process.env.MAX_DESCRIPTION_LENGTH, 10) : undefined,
// 网络请求超时时间(毫秒),默认 30 秒
requestTimeout: process.env.REQUEST_TIMEOUT ? parseInt(process.env.REQUEST_TIMEOUT, 10) : 30000
};

// Valid search engines list
Expand Down
120 changes: 64 additions & 56 deletions src/engines/baidu/baidu.ts
Original file line number Diff line number Diff line change
@@ -1,72 +1,80 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import { SearchResult } from '../../types.js';
import { getSharedBrowser, destroySharedBrowser } from '../shared/browser.js';
import { config } from '../../config.js';

export async function searchBaidu(query: string, limit: number): Promise<SearchResult[]> {
let allResults: SearchResult[] = [];
let pn = 0;
try {
const browser = await getSharedBrowser();
let allResults: SearchResult[] = [];
let pn = 0;

while (allResults.length < limit) {
const response = await axios.get('https://www.baidu.com/s', {
params: {
wd: query,
pn: pn.toString(),
ie: "utf-8",
mod: "1",
isbd: "1",
isid: "f7ba1776007bcf9e",
oq: query,
tn: "88093251_62_hao_pg",
usm: "1",
fenlei: "256",
rsv_idx: "1",
rsv_pq: "f7ba1776007bcf9e",
rsv_t: "8179fxGiNMUh/0dXHrLsJXPlKYbkj9S5QH6rOLHY6pG6OGQ81YqzRTIGjjeMwEfiYQTSiTQIhCJj",
bs: query,
rsv_sid: undefined,
_ss: "1",
f4s: "1",
csor: "5",
_cr1: "30385",
},
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
while (allResults.length < limit) {
const page = await browser.newPage();

const searchUrl = `https://www.baidu.com/s?wd=${encodeURIComponent(query)}&pn=${pn}&ie=utf-8`;

try {
await page.goto(searchUrl, { waitUntil: 'networkidle2', timeout: config.requestTimeout });
} catch (navErr: any) {
// 百度可能在导航时销毁 iframe(如天气小组件),回退到 domcontentloaded
if (navErr.message?.includes('frame was detached') || navErr.message?.includes('Navigating frame')) {
console.error('⚠️ Baidu frame detached during navigation, retrying with domcontentloaded...');
await page.goto(searchUrl, { waitUntil: 'domcontentloaded', timeout: config.requestTimeout });
} else {
throw navErr;
}
}
});
await new Promise(r => setTimeout(r, 1000));

const $ = cheerio.load(response.data);
const results: SearchResult[] = [];
const html = await page.content();
await page.close();

$('#content_left').children().each((i, element) => {
const titleElement = $(element).find('h3');
const linkElement = $(element).find('a');
const snippetElement = $(element).find('.cos-row').first();
const $ = cheerio.load(html);

if (titleElement.length && linkElement.length) {
const url = linkElement.attr('href');
if (url && url.startsWith('http')) {
const snippetElementBaidu = $(element).find('.c-font-normal.c-color-text').first();
const sourceElement = $(element).find('.cosc-source');
results.push({
title: titleElement.text(),
url: url,
description: snippetElementBaidu.attr('aria-label') || snippetElement.text().trim() || '',
source: sourceElement.text().trim() || '',
engine: 'baidu'
});
}
// 检测百度安全验证页面
const title = $('title').text();
if (title.includes('安全验证')) {
console.error('⚠️ Baidu security verification detected, no results for this page.');
break;
}
});

allResults = allResults.concat(results);
const results: SearchResult[] = [];

$('#content_left').children().each((i, element) => {
const titleElement = $(element).find('h3');
const linkElement = $(element).find('a');
const snippetElement = $(element).find('.cos-row').first();

if (results.length === 0) {
console.error('⚠️ No more results, ending early....');
break;
if (titleElement.length && linkElement.length) {
const url = linkElement.attr('href');
if (url && url.startsWith('http')) {
const snippetElementBaidu = $(element).find('.c-font-normal.c-color-text').first();
const sourceElement = $(element).find('.cosc-source');
results.push({
title: titleElement.text(),
url: url,
description: snippetElementBaidu.attr('aria-label') || snippetElement.text().trim() || '',
source: sourceElement.text().trim() || '',
engine: 'baidu'
});
}
}
});

allResults = allResults.concat(results);

if (results.length === 0) {
console.error('⚠️ No more results, ending early....');
break;
}

pn += 10;
}

pn += 10;
return allResults.slice(0, limit);
} catch (err) {
await destroySharedBrowser();
throw err;
}

return allResults.slice(0, limit); // 截取最多 limit 个
}
156 changes: 98 additions & 58 deletions src/engines/bing/bing.ts
Original file line number Diff line number Diff line change
@@ -1,72 +1,112 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import { SearchResult } from '../../types.js';
import { getSharedBrowser, destroySharedBrowser } from '../shared/browser.js';
import { config } from '../../config.js';

export async function searchBing(query: string, limit: number): Promise<SearchResult[]> {
let allResults: SearchResult[] = [];
let pn = 0;
/**
* 解码 Bing 重定向 URL,提取实际目标地址。
* Bing URL 格式: https://www.bing.com/ck/a?...&u=a1<Base64编码的URL>
* 参数 'u' 的值以 'a1' 开头,后接 Base64 编码的原始 URL。
*/
function decodeBingUrl(bingUrl: string): string {
try {
const url = new URL(bingUrl);
const encodedUrl = url.searchParams.get('u');
if (!encodedUrl) {
return bingUrl;
}
const base64Part = encodedUrl.substring(2);
const decodedUrl = Buffer.from(base64Part, 'base64').toString('utf-8');
if (decodedUrl.startsWith('http')) {
return decodedUrl;
}
return bingUrl;
} catch {
return bingUrl;
}
}

while (allResults.length < limit) {
const response = await axios.get('https://www.bing.com/search', {
params: {
q: query,
first: 1 + pn * 10
},
headers: {
"authority": "www.bing.com",
"ect": "3g",
"pragma": "no-cache",
"sec-ch-ua-arch": "\"x86\"",
"sec-ch-ua-bitness": "\"64\"",
"sec-ch-ua-full-version": "\"112.0.5615.50\"",
"sec-ch-ua-full-version-list": "\"Chromium\";v=\"112.0.5615.50\", \"Google Chrome\";v=\"112.0.5615.50\", \"Not:A-Brand\";v=\"99.0.0.0\"",
"sec-ch-ua-model": "\"\"",
"sec-ch-ua-platform-version": "\"15.0.0\"",
"sec-fetch-user": "?1",
"upgrade-insecure-requests": "1",
"Cookie": "MUID=3727DBB14FD763511D80CDBD4ED262EF; MSPTC=5UlNf4UsLqV53oFqqdHiR26FwDDL8zSW3kC74kIJQfM; _EDGE_S=SID=132F08F578E06F832D931EE779E16E2D; MUIDB=3727DBB14FD763511D80CDBD4ED262EF; SRCHD=AF=NOFORM; SRCHUID=V=2&GUID=B3AFD0E41DB649E39803C690946C3B65&dmnchg=1; ak_bmsc=578AE2B7DA55FA9F332ADCDFBA0B9B64~000000000000000000000000000000~YAAQZCg0F9XLkYGXAQAAjywwkhxcD6Pm2nguBmpB14hnmCR3kz9Mfau5cZ7pwHxdU2Uog9+6hOkBmzpOV3UoTOhi52nB725xM7zN90mRDv0zQtJdO/llaKlt2zqTmB4F5kd+GzPjXLAN4Zmj4KwpAjLK1T4TexH/9WlQTkRamdJTKuR47IZWHHebqsbNqHoYncHhxICO9Rnu51vhlps/rrhPBtgPgbrQnDfr6YzAQWmSqc5g9hk03sM9nnWUyVbRV0ZVsgke7BCYX5V1JD5L0Zf8/FWdntBpjpd2IcmehBz38ChGThPrBEWNCZQbCS6lE4OaQanrrdmBHf/r5YEf2LeIqZy0bJGIiSQaSh6d7KFO2haTQk/JscZAs+V5kNsAOxIGreRve+E=; _UR=QS=0&TQS=0&Pn=0; BFBUSR=BFBHP=0; SRCHUSR=DOB=20250621&DS=1; _Rwho=u=d&ts=2025-06-21; ipv6=hit=1750507922628&t=4; BFPRResults=FirstPageUrls=C5E678E900F98310F0D3DB1F3EB96D99%2CB5A20FAE72B0C3019A56409EAC7AF3FB%2C7A44A77FF42EDF11CC9BF5CFE08B179A%2C6ED615E5E634BD5AFC7BB2A0A77F8FF8%2CA993E7AAF4890BEC06882621CA376D00%2C49CF0FC3C203D5E918A76258506B0CF4%2C7F03D5026C1D046F66B11D525095BF8B%2C058BB67A6B7F15E58D3A19B897BC57F8%2C1B886024FDE703428D24A41AFA1E62AF%2C5A8B56DC0AE03A8B94643DEA2A22DBAC&FPIG=05F126AA95514CF5AD5E33E4AEBA474D; _HPVN=CS=eyJQbiI6eyJDbiI6MSwiU3QiOjAsIlFzIjowLCJQcm9kIjoiUCJ9LCJTYyI6eyJDbiI6MSwiU3QiOjAsIlFzIjowLCJQcm9kIjoiSCJ9LCJReiI6eyJDbiI6MSwiU3QiOjAsIlFzIjowLCJQcm9kIjoiVCJ9LCJBcCI6dHJ1ZSwiTXV0ZSI6dHJ1ZSwiTGFkIjoiMjAyNS0wNi0yMVQwMDowMDowMFoiLCJJb3RkIjowLCJHd2IiOjAsIlRucyI6MCwiRGZ0IjpudWxsLCJNdnMiOjAsIkZsdCI6MCwiSW1wIjoxNSwiVG9ibiI6MH0=; _C_ETH=1; _RwBf=r=0&ilt=15&ihpd=1&ispd=14&rc=36&rb=0&rg=200&pc=36&mtu=0&rbb=0&clo=0&v=15&l=2025-06-21T07:00:00.0000000Z&lft=0001-01-01T00:00:00.0000000&aof=0&ard=0001-01-01T00:00:00.0000000&rwdbt=0&rwflt=0&rwaul2=0&g=&o=2&p=&c=&t=0&s=0001-01-01T00:00:00.0000000+00:00&ts=2025-06-21T11:36:08.7064260+00:00&rwred=0&wls=&wlb=&wle=&ccp=&cpt=&lka=0&lkt=0&aad=0&TH=&cid=0&gb=; _SS=SID=132F08F578E06F832D931EE779E16E2D&R=36&RB=0&GB=0&RG=200&RP=36; SRCHHPGUSR=SRCHLANG=zh-Hans&IG=63A0A44F5D2F4499AD165A366D073C03&DM=0&BRW=N&BRH=T&CW=1202&CH=1289&SCW=1185&SCH=2279&DPR=1.0&UTC=480&HV=1750505768&HVE=notFound&WTS=63886101120&PV=15.0.0&PRVCW=1202&PRVCH=1289&EXLTT=13; SRCHHPGUSR=SRCHLANG=en&IG=9A53F826E9C9432497327CA995144E14&DM=0&BRW=N&BRH=T&CW=1202&CH=1289&SCW=1185&SCH=2279&DPR=1.0&UTC=480&HV=1750505768&HVE=notFound&WTS=63886101120&PV=15.0.0&PRVCW=1202&PRVCH=1289&EXLTT=13",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36",
"Accept": "*/*",
"Host": "cn.bing.com",
"Connection": "keep-alive"
function parsePageResults(html: string): SearchResult[] {
const $ = cheerio.load(html);
const results: SearchResult[] = [];
$('#b_results h2').each((i, element) => {
const linkElement = $(element).find('a').first();
if (linkElement.length) {
const rawUrl = linkElement.attr('href');
if (rawUrl && rawUrl.startsWith('http')) {
const url = decodeBingUrl(rawUrl);
const parentLi = $(element).closest('li');
const snippetElement = parentLi.find('p').first();
const sourceElement = parentLi.find('.b_tpcn');
results.push({
title: linkElement.text().trim(),
url: url,
description: snippetElement.text().trim() || '',
source: sourceElement.text().trim() || '',
engine: 'bing'
});
}
});
}
});
return results;
}

const $ = cheerio.load(response.data);
const results: SearchResult[] = [];
export async function searchBing(query: string, limit: number): Promise<SearchResult[]> {
try {
const browser = await getSharedBrowser();
const page = await browser.newPage();

$('#b_content').children()
.find('#b_results').children()
.each((i, element) => {
const titleElement = $(element).find('h2');
const linkElement = $(element).find('a');
const snippetElement = $(element).find('p').first();
try {
const searchUrl = `https://www.bing.com/search?q=${encodeURIComponent(query)}`;
await page.goto(searchUrl, { waitUntil: 'networkidle2', timeout: config.requestTimeout });

if (titleElement.length && linkElement.length) {
const url = linkElement.attr('href');
if (url && url.startsWith('http')) {
// cn.bing.com 等本地化版本可能异步渲染搜索结果,
// networkidle2 无法保证 DOM 已就绪,需要显式等待结果选择器。
try {
await page.waitForSelector('#b_results .b_algo', { timeout: 10000 });
} catch {
// 可能确实没有结果,或者 Bing 使用了不同的页面结构,继续尝试解析
console.warn('[bing] 等待搜索结果选择器 #b_results .b_algo 超时,页面 URL:', page.url());
}

const sourceElement = $(element).find('.b_tpcn');
results.push({
title: titleElement.text(),
url: url,
description: snippetElement.text().trim() || '',
source: sourceElement.text().trim() || '',
engine: 'bing'
});
}
}
});
let allResults = parsePageResults(await page.content());

allResults = allResults.concat(results);
// 如果首次解析为空,可能是异步渲染还没完成(cn.bing.com 特有的延迟),
// 或者出现了 cookie 同意弹窗等遮挡,尝试再等待一次。
if (allResults.length === 0) {
console.warn('[bing] 首次解析返回 0 条结果,等待 3 秒后重试...');
await new Promise(r => setTimeout(r, 3000));
allResults = parsePageResults(await page.content());
}

if (results.length === 0) {
console.error('⚠️ No more results, ending early....');
break;
}
while (allResults.length < limit) {
const nextLink = await page.$('.sb_pagN');
if (!nextLink) break;
// Bing 翻页可能用完整导航或 AJAX,两种方式都要兼容
const navPromise = page.waitForNavigation({ waitUntil: 'networkidle2', timeout: config.requestTimeout }).catch(() => {});
await nextLink.click();
await navPromise;
try {
await page.waitForSelector('#b_results .b_algo', { timeout: 10000 });
} catch {
// 翻页后如果超时,继续尝试解析
}
const pageResults = parsePageResults(await page.content());
if (pageResults.length === 0) break;
allResults = allResults.concat(pageResults);
}

pn += 1;
const finalResults = allResults.slice(0, limit);
if (finalResults.length === 0) {
const finalUrl = page.url();
console.warn(`[bing] 搜索返回 0 条结果。最终 URL: ${finalUrl}。页面可能出现了验证码、Cookie 同意弹窗,或 HTML 结构已变更。`);
}
return finalResults;
} finally {
await page.close();
}
} catch (err) {
await destroySharedBrowser();
throw err;
}

return allResults.slice(0, limit); // 截取最多 limit 个
}
4 changes: 2 additions & 2 deletions src/engines/brave/brave.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import axios from 'axios';
import * as cheerio from 'cheerio';
import { SearchResult } from '../../types.js';
import {getProxyUrl} from "../../config.js";
import {getProxyUrl, config} from "../../config.js";
import {HttpsProxyAgent} from "https-proxy-agent";

export async function searchBrave(query: string, limit: number): Promise<SearchResult[]> {
Expand Down Expand Up @@ -39,7 +39,7 @@ export async function searchBrave(query: string, limit: number): Promise<SearchR

const encodedQuery = encodeURIComponent(query);
while (allResults.length < limit) {
const response = await axios.get(`https://search.brave.com/search?q=${encodedQuery}&source=web&offset=${pn}`, requestOptions)
const response = await axios.get(`https://search.brave.com/search?q=${encodedQuery}&source=web&offset=${pn}`, { ...requestOptions, timeout: config.requestTimeout })

const $ = cheerio.load(response.data);
const results: SearchResult[] = [];
Expand Down
Loading