kiku-agent 1.2.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +459 -407
- package/package.json +32 -3
- package/server/agent/agentCore.js +635 -267
- package/server/agent/appPaths.js +2 -0
- package/server/agent/catalogEntityResolver.js +637 -0
- package/server/agent/citations.js +0 -21
- package/server/agent/clarificationAnalytics.js +1 -1
- package/server/agent/clarifyingQuestions.js +286 -216
- package/server/agent/cli.js +532 -73
- package/server/agent/commandFeatures.js +123 -24
- package/server/agent/commandRegistry.js +128 -62
- package/server/agent/commands.js +1410 -95
- package/server/agent/config.js +253 -163
- package/server/agent/contextCompactor.js +239 -25
- package/server/agent/dynamicCatalog.js +192 -0
- package/server/agent/envBootstrap.js +46 -0
- package/server/agent/envManager.js +317 -0
- package/server/agent/eventBus.js +3 -3
- package/server/agent/feedbackLearning.js +0 -8
- package/server/agent/gpuMetrics.js +188 -55
- package/server/agent/ingestionWorker.js +339 -0
- package/server/agent/ink/App.jsx +348 -0
- package/server/agent/ink/Panel.jsx +54 -0
- package/server/agent/ink/PromptInput.jsx +321 -0
- package/server/agent/ink/TaskPlanPanel.jsx +88 -0
- package/server/agent/ink/ThinkingPanel.jsx +68 -0
- package/server/agent/ink/ToolResult.jsx +133 -0
- package/server/agent/ink/main.jsx +195 -0
- package/server/agent/ink/useEventBus.js +218 -0
- package/server/agent/lanDiscovery.js +369 -0
- package/server/agent/llmClient.js +111 -39
- package/server/agent/lmStudioWakeup.js +144 -0
- package/server/agent/mcpClient.js +2 -2
- package/server/agent/memory/contextAllocator.js +214 -0
- package/server/agent/memory/hierarchicalStore.js +523 -0
- package/server/agent/memory/reflectionWorker.js +110 -0
- package/server/agent/memory.js +635 -494
- package/server/agent/modelConfig.js +487 -0
- package/server/agent/newsIntelligence.js +219 -0
- package/server/agent/newsWatch.js +85 -0
- package/server/agent/prompts.js +146 -0
- package/server/agent/publicTools.js +7 -0
- package/server/agent/queryDetails.js +62 -1
- package/server/agent/research.js +314 -35
- package/server/agent/routingInterceptor.js +77 -0
- package/server/agent/rumoredProjects.js +15 -0
- package/server/agent/sanitizer.js +117 -17
- package/server/agent/subagent.js +52 -9
- package/server/agent/suggestionTui.js +33 -13
- package/server/agent/suggestions.js +29 -13
- package/server/agent/telemetry/hardwareTiers.js +332 -0
- package/server/agent/telemetry/runtimeWatchdog.js +66 -0
- package/server/agent/telemetry/techPowerUpClient.js +682 -0
- package/server/agent/telemetry/techpowerup-mcp.json +22 -0
- package/server/agent/telemetry.js +0 -11
- package/server/agent/terminalImage.js +0 -1
- package/server/agent/textToolProtocol.js +190 -0
- package/server/agent/tokenTracker.js +220 -0
- package/server/agent/toolRouter.js +362 -216
- package/server/agent/tools.js +1155 -194
- package/server/agent/ui.js +144 -41
- package/server/agent/vramService.js +572 -0
- package/server/agent/webSearch.js +2279 -273
- package/server/agent/websearch/adapters/duckduckgo.js +97 -0
- package/server/agent/websearch/adapters/jina.js +77 -0
- package/server/agent/websearch/adapters/searxng.js +57 -0
- package/server/agent/websearch/core/canonical.js +89 -0
- package/server/agent/websearch/core/fusion.js +81 -0
- package/server/agent/websearch/core/types.js +29 -0
- package/server/agent/websearch/extract.js +605 -0
- package/server/agent/websearch/index.js +43 -0
- package/server/agent/websearch/orchestrator.js +266 -0
- package/server/agent/websearch/resilience/breaker.js +97 -0
- package/server/agent/websearch/resilience/cache.js +59 -0
- package/server/agent/websearch/resilience/limiter.js +93 -0
- package/server/agent/websearch/security/filter.js +36 -0
- package/server/agent/websearch/security/ssrf.js +289 -0
- package/server/agent/websearch/telemetry.js +71 -0
- package/server/agent/youtubeAuth.js +82 -73
- package/server/agent/youtubeTranscript.js +209 -0
- package/server/apiRouter.js +390 -190
- package/server/mcp/data/brands.js +5 -5
- package/server/mcp/data/discography.js +56 -45
- package/server/mcp/data/gallery.js +9 -0
- package/server/mcp/data/index.js +1 -0
- package/server/mcp/data/industryIndex.js +251 -0
- package/server/mcp/data/profile.js +5 -5
- package/server/mcp/data/timeline.js +2 -2
- package/server/mcp/data/works.js +2 -2
- package/server/mcp/handler.js +0 -2
- package/server/mcp/prompts.js +42 -2
- package/server/mcp/resources.js +5 -1
- package/server/mcp/security.js +25 -14
- package/server/mcp/server.js +92 -3
- package/server/mcp/tools.js +20 -11
- package/server/routes/agent.js +55 -0
- package/server/web-chat/core/connection.js +218 -194
- package/server/web-chat/core/conversationContext.js +1 -1
- package/server/web-chat/core/directCatalogEngine.js +190 -184
- package/server/web-chat/core/followUpEngine.js +88 -11
- package/server/web-chat/core/security.js +7 -4
- package/server/web-chat/router.js +54 -17
- package/server/web-chat/routes/bilibiliRoute.js +34 -0
- package/server/web-chat/routes/chatRoute.js +488 -397
- package/server/web-chat/routes/clarifyConfigRoute.js +1 -2
- package/server/web-chat/routes/exportRoute.js +2 -1
- package/server/web-chat/routes/lanDiscoveryRoute.js +90 -0
- package/server/web-chat/routes/newsRoute.js +36 -6
- package/server/web-chat/routes/providerRoute.js +87 -0
- package/server/web-chat/routes/staticRoute.js +61 -13
- package/server/web-chat/routes/statusRoute.js +23 -2
- package/server/web-chat/routes/vramRoute.js +22 -0
- package/server/web-chat/server.js +135 -106
- package/server/web-chat/ui/htmlBuilder.js +8 -8
- package/server/web-chat/ui/previews/modelSettings.preview.html +41 -0
- package/server/web-chat/ui/scripts/agentBubbleSystem.js +0 -5
- package/server/web-chat/ui/scripts/client.js +10526 -8397
- package/server/web-chat/ui/scripts/webchatI18n.js +190 -138
- package/server/web-chat/ui/styles/main.css +7471 -4246
- package/server/web-chat/ui/templates/body.html +305 -179
- package/src/data/brandsData.js +29 -6
- package/src/data/discographyData.js +918 -900
- package/src/data/galleryData.js +66 -0
- package/src/data/industryIndexData.js +292 -0
- package/src/data/musicVideoData.js +48 -5
- package/src/data/profileData.js +15 -1
- package/src/data/timelineData.js +4 -3
- package/src/data/worksData.js +536 -535
- package/src/lib/vram/estimator.js +464 -0
- package/src/lib/vram/modelRegistry.js +387 -0
- package/tokens.css +33 -2
|
@@ -3,75 +3,281 @@ import { execFile } from 'node:child_process';
|
|
|
3
3
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import https from 'node:https';
|
|
8
|
+
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
9
|
+
import { assertSafeUrlHostname, sanitizeUrl, isPrivateIPv4, isPrivateIPv6, isIpAddress } from './sanitizer.js';
|
|
10
|
+
import { getContextualizedSearchQuery } from './catalogEntityResolver.js';
|
|
11
|
+
import { galleryData } from '../../src/data/galleryData.js';
|
|
12
|
+
import { discographyData } from '../../src/data/discographyData.js';
|
|
13
|
+
import { musicVideoData } from '../../src/data/musicVideoData.js';
|
|
14
|
+
import { profileData } from '../../src/data/profileData.js';
|
|
15
|
+
import { defaultMetaSearch, hybridWebSearch } from './websearch/index.js';
|
|
16
|
+
import { computeSearchCacheKey, TTL_GENERAL_MS } from './websearch/resilience/cache.js';
|
|
17
|
+
import {
|
|
18
|
+
decodeHtmlBytes,
|
|
19
|
+
decodeEntities,
|
|
20
|
+
normalizeText,
|
|
21
|
+
loadCheerio,
|
|
22
|
+
extractMainContent,
|
|
23
|
+
splitIntoPassages,
|
|
24
|
+
selectPassages,
|
|
25
|
+
filterUniquePassages,
|
|
26
|
+
truncateByWords,
|
|
27
|
+
} from './websearch/extract.js';
|
|
28
|
+
|
|
29
|
+
export { defaultMetaSearch, hybridWebSearch };
|
|
30
|
+
|
|
31
|
+
export class BoundedLruCache {
|
|
32
|
+
constructor(maxSize = 500) {
|
|
33
|
+
this.maxSize = Math.max(10, maxSize);
|
|
34
|
+
this.cache = new Map();
|
|
35
|
+
}
|
|
7
36
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
37
|
+
get(key) {
|
|
38
|
+
const entry = this.cache.get(key);
|
|
39
|
+
if (!entry) return undefined;
|
|
40
|
+
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
41
|
+
this.cache.delete(key);
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
this.cache.delete(key);
|
|
45
|
+
this.cache.set(key, entry);
|
|
46
|
+
return entry.value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
set(key, value, ttlMs = 0) {
|
|
50
|
+
if (this.cache.has(key)) {
|
|
51
|
+
this.cache.delete(key);
|
|
52
|
+
} else if (this.cache.size >= this.maxSize) {
|
|
53
|
+
const oldestKey = this.cache.keys().next().value;
|
|
54
|
+
if (oldestKey !== undefined) this.cache.delete(oldestKey);
|
|
55
|
+
}
|
|
56
|
+
const expiresAt = ttlMs > 0 ? Date.now() + ttlMs : 0;
|
|
57
|
+
this.cache.set(key, { value, expiresAt });
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
has(key) {
|
|
62
|
+
return this.get(key) !== undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
delete(key) {
|
|
66
|
+
return this.cache.delete(key);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
clear() {
|
|
70
|
+
this.cache.clear();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get size() {
|
|
74
|
+
return this.cache.size;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let _chromeConcurrency = 0;
|
|
79
|
+
const _chromeWaiters = [];
|
|
80
|
+
const MAX_CHROME_CONCURRENCY = 2;
|
|
81
|
+
|
|
82
|
+
async function acquireChromeLock(timeoutMs = 20000) {
|
|
83
|
+
if (_chromeConcurrency < MAX_CHROME_CONCURRENCY) {
|
|
84
|
+
_chromeConcurrency++;
|
|
85
|
+
return () => releaseChromeLock();
|
|
86
|
+
}
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
let entry = null;
|
|
89
|
+
const timer = setTimeout(() => {
|
|
90
|
+
const idx = _chromeWaiters.indexOf(entry);
|
|
91
|
+
if (idx !== -1) _chromeWaiters.splice(idx, 1);
|
|
92
|
+
reject(new Error('Chrome screenshot queue timed out waiting for an available browser process'));
|
|
93
|
+
}, timeoutMs);
|
|
94
|
+
entry = () => {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
_chromeConcurrency++;
|
|
97
|
+
resolve(() => releaseChromeLock());
|
|
98
|
+
};
|
|
99
|
+
_chromeWaiters.push(entry);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function releaseChromeLock() {
|
|
104
|
+
_chromeConcurrency = Math.max(0, _chromeConcurrency - 1);
|
|
105
|
+
if (_chromeWaiters.length > 0) {
|
|
106
|
+
const next = _chromeWaiters.shift();
|
|
107
|
+
next();
|
|
17
108
|
}
|
|
18
109
|
}
|
|
19
110
|
|
|
20
111
|
function cleanHtml(html) {
|
|
21
112
|
if (!html) return '';
|
|
22
|
-
return
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
.replace(/<aside\b[^<]*(?:(?!<\/aside>)<[^<]*)*<\/aside>/gi, ' ')
|
|
29
|
-
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, ' ')
|
|
30
|
-
.replace(/<noscript\b[^<]*(?:(?!<\/noscript>)<[^<]*)*<\/noscript>/gi, ' ')
|
|
31
|
-
.replace(/<[^>]+>/g, ' ')
|
|
32
|
-
.replace(/ /g, ' ')
|
|
33
|
-
.replace(/&/g, '&')
|
|
34
|
-
.replace(/</g, '<')
|
|
35
|
-
.replace(/>/g, '>')
|
|
36
|
-
.replace(/"/g, '"')
|
|
37
|
-
.replace(/'/g, "'")
|
|
38
|
-
.replace(/—/g, '—')
|
|
39
|
-
.replace(/–/g, '–')
|
|
40
|
-
.replace(/…/g, '...')
|
|
41
|
-
.replace(/\s+/g, ' ')
|
|
42
|
-
.trim();
|
|
113
|
+
return normalizeText(decodeEntities(
|
|
114
|
+
String(html)
|
|
115
|
+
.replace(/<script\b[\s\S]*?<\/script>/gi, ' ')
|
|
116
|
+
.replace(/<style\b[\s\S]*?<\/style>/gi, ' ')
|
|
117
|
+
.replace(/<[^>]+>/g, ' ')
|
|
118
|
+
));
|
|
43
119
|
}
|
|
44
120
|
|
|
45
121
|
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
46
122
|
|
|
123
|
+
const SAFE_FETCH_DNS_CACHE = new Map();
|
|
124
|
+
const SAFE_FETCH_DNS_SUCCESS_TTL_MS = 60_000;
|
|
125
|
+
const SAFE_FETCH_DNS_FAIL_TTL_MS = 10_000;
|
|
126
|
+
const SAFE_FETCH_DNS_CACHE_MAX = 500;
|
|
127
|
+
|
|
128
|
+
function setSafeFetchDnsEntry(hostname, entry) {
|
|
129
|
+
if (!SAFE_FETCH_DNS_CACHE.has(hostname) && SAFE_FETCH_DNS_CACHE.size >= SAFE_FETCH_DNS_CACHE_MAX) {
|
|
130
|
+
const oldestKey = SAFE_FETCH_DNS_CACHE.keys().next().value;
|
|
131
|
+
if (oldestKey !== undefined) SAFE_FETCH_DNS_CACHE.delete(oldestKey);
|
|
132
|
+
}
|
|
133
|
+
SAFE_FETCH_DNS_CACHE.set(hostname, entry);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function safeFetchDnsLookup(hostname) {
|
|
137
|
+
const cached = SAFE_FETCH_DNS_CACHE.get(hostname);
|
|
138
|
+
if (cached && cached.expires > Date.now()) {
|
|
139
|
+
if (cached.error) throw new Error(cached.error);
|
|
140
|
+
return cached.addresses;
|
|
141
|
+
}
|
|
142
|
+
if (cached) SAFE_FETCH_DNS_CACHE.delete(hostname);
|
|
143
|
+
try {
|
|
144
|
+
const addresses = await dnsLookup(hostname, { all: true });
|
|
145
|
+
setSafeFetchDnsEntry(hostname, { expires: Date.now() + SAFE_FETCH_DNS_SUCCESS_TTL_MS, addresses });
|
|
146
|
+
return addresses;
|
|
147
|
+
} catch (dnsErr) {
|
|
148
|
+
const message = `Unable to resolve hostname ${hostname}: ${dnsErr.message}`;
|
|
149
|
+
setSafeFetchDnsEntry(hostname, { expires: Date.now() + SAFE_FETCH_DNS_FAIL_TTL_MS, error: message });
|
|
150
|
+
throw new Error(message);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
47
154
|
export async function safeFetch(inputUrl, options = {}, maxRedirects = 5) {
|
|
48
155
|
let currentUrl = String(inputUrl || '');
|
|
49
|
-
let currentOptions = { ...options
|
|
156
|
+
let currentOptions = { ...options };
|
|
50
157
|
|
|
51
158
|
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
|
|
52
159
|
const sanitized = sanitizeUrl(currentUrl);
|
|
53
160
|
if (!sanitized.safe) throw new Error(`URL rejected by SSRF guard: ${sanitized.reason}`);
|
|
54
|
-
const dnsCheck = await assertSafeUrlHostname(sanitized.hostname);
|
|
55
|
-
if (!dnsCheck.safe) throw new Error(`URL rejected by SSRF guard: ${dnsCheck.reason}`);
|
|
56
161
|
|
|
57
|
-
const
|
|
162
|
+
const parsedUrl = sanitized.url;
|
|
163
|
+
const hostname = sanitized.hostname;
|
|
164
|
+
|
|
165
|
+
let targetIp = hostname;
|
|
166
|
+
if (!isIpAddress(hostname)) {
|
|
167
|
+
const addresses = await safeFetchDnsLookup(hostname);
|
|
168
|
+
if (!addresses || addresses.length === 0) {
|
|
169
|
+
throw new Error(`Unable to resolve hostname ${hostname}`);
|
|
170
|
+
}
|
|
171
|
+
for (const { address } of addresses) {
|
|
172
|
+
if (isPrivateIPv4(address) || isPrivateIPv6(address)) {
|
|
173
|
+
throw new Error(`Host resolved to private/internal IP: ${address}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
targetIp = addresses[0].address;
|
|
177
|
+
} else {
|
|
178
|
+
if (isPrivateIPv4(hostname) || isPrivateIPv6(hostname)) {
|
|
179
|
+
throw new Error(`Host is private/internal IP: ${hostname}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const isHttps = parsedUrl.protocol === 'https:';
|
|
184
|
+
const client = isHttps ? https : http;
|
|
185
|
+
const port = parsedUrl.port ? Number(parsedUrl.port) : (isHttps ? 443 : 80);
|
|
186
|
+
const method = String(currentOptions.method || 'GET').toUpperCase();
|
|
187
|
+
|
|
188
|
+
const headers = { ...(currentOptions.headers || {}) };
|
|
189
|
+
if (!headers.host && !headers.Host) {
|
|
190
|
+
headers.Host = parsedUrl.host;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const response = await new Promise((resolve, reject) => {
|
|
194
|
+
let req = null;
|
|
195
|
+
try {
|
|
196
|
+
req = client.request({
|
|
197
|
+
host: targetIp,
|
|
198
|
+
port,
|
|
199
|
+
path: parsedUrl.pathname + parsedUrl.search,
|
|
200
|
+
method,
|
|
201
|
+
headers,
|
|
202
|
+
servername: isHttps ? hostname : undefined,
|
|
203
|
+
signal: currentOptions.signal,
|
|
204
|
+
timeout: currentOptions.timeout || 15000
|
|
205
|
+
}, res => {
|
|
206
|
+
const chunks = [];
|
|
207
|
+
let bytesReceived = 0;
|
|
208
|
+
let aborted = false;
|
|
209
|
+
const maxBytes = currentOptions.maxBytes || 5_000_000;
|
|
210
|
+
|
|
211
|
+
const finish = () => {
|
|
212
|
+
if (aborted) return;
|
|
213
|
+
aborted = true;
|
|
214
|
+
const bodyBuffer = Buffer.concat(chunks);
|
|
215
|
+
const responseHeaders = new Headers();
|
|
216
|
+
for (const [k, v] of Object.entries(res.headers)) {
|
|
217
|
+
if (Array.isArray(v)) {
|
|
218
|
+
v.forEach(val => responseHeaders.append(k, val));
|
|
219
|
+
} else if (v !== undefined) {
|
|
220
|
+
responseHeaders.set(k, v);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
resolve({
|
|
225
|
+
status: res.statusCode || 200,
|
|
226
|
+
statusText: res.statusMessage || '',
|
|
227
|
+
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
228
|
+
headers: responseHeaders,
|
|
229
|
+
url: parsedUrl.href,
|
|
230
|
+
text: async () => bodyBuffer.toString('utf8'),
|
|
231
|
+
json: async () => JSON.parse(bodyBuffer.toString('utf8')),
|
|
232
|
+
buffer: async () => bodyBuffer
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
res.on('data', chunk => {
|
|
237
|
+
bytesReceived += chunk.length;
|
|
238
|
+
if (bytesReceived > maxBytes) {
|
|
239
|
+
finish();
|
|
240
|
+
req.destroy();
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
chunks.push(chunk);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
res.on('end', finish);
|
|
247
|
+
res.on('error', () => finish());
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
req.on('error', reject);
|
|
251
|
+
req.on('timeout', () => {
|
|
252
|
+
req.destroy(new Error(`Request to ${hostname} timed out`));
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
if (currentOptions.body) {
|
|
256
|
+
req.write(currentOptions.body);
|
|
257
|
+
}
|
|
258
|
+
req.end();
|
|
259
|
+
} catch (err) {
|
|
260
|
+
reject(err);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
58
264
|
if (!REDIRECT_STATUSES.has(response.status)) return response;
|
|
59
265
|
|
|
60
266
|
const location = response.headers.get('location');
|
|
61
267
|
if (!location) return response;
|
|
62
268
|
if (redirects === maxRedirects) throw new Error('Too many redirects');
|
|
63
269
|
|
|
64
|
-
const nextUrl = new URL(location,
|
|
270
|
+
const nextUrl = new URL(location, parsedUrl).href;
|
|
65
271
|
const nextOrigin = new URL(nextUrl).origin;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
headers.
|
|
70
|
-
headers.
|
|
272
|
+
delete headers.host;
|
|
273
|
+
delete headers.Host;
|
|
274
|
+
if (nextOrigin !== parsedUrl.origin) {
|
|
275
|
+
delete headers.authorization;
|
|
276
|
+
delete headers.cookie;
|
|
277
|
+
delete headers['proxy-authorization'];
|
|
71
278
|
}
|
|
72
|
-
const method = String(currentOptions.method || 'GET').toUpperCase();
|
|
73
279
|
if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === 'POST')) {
|
|
74
|
-
headers
|
|
280
|
+
delete headers['content-type'];
|
|
75
281
|
currentOptions = { ...currentOptions, method: 'GET', body: undefined, headers };
|
|
76
282
|
} else {
|
|
77
283
|
currentOptions = { ...currentOptions, headers };
|
|
@@ -81,32 +287,106 @@ export async function safeFetch(inputUrl, options = {}, maxRedirects = 5) {
|
|
|
81
287
|
throw new Error('Too many redirects');
|
|
82
288
|
}
|
|
83
289
|
|
|
84
|
-
async function
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
let text = '';
|
|
290
|
+
export async function checkUrlLiveness(inputUrl, { timeoutMs = 2500 } = {}) {
|
|
291
|
+
if (!inputUrl || typeof inputUrl !== 'string' || !/^https?:\/\//i.test(inputUrl)) {
|
|
292
|
+
return { alive: false, reason: 'invalid_url' };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (inputUrl.includes('youtube.com/results') || inputUrl.includes('google.com/search') || inputUrl.includes('wikipedia.org') || inputUrl.includes('example.com') || inputUrl.includes('example.org') || inputUrl.includes('example.net')) {
|
|
296
|
+
return { alive: true };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const controller = new AbortController();
|
|
300
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
301
|
+
|
|
97
302
|
try {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
303
|
+
let res = await safeFetch(inputUrl, {
|
|
304
|
+
method: 'HEAD',
|
|
305
|
+
headers: {
|
|
306
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
307
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
|
|
308
|
+
},
|
|
309
|
+
signal: controller.signal
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
if (res.status === 405 || res.status === 404 || res.status === 403 || res.status === 501) {
|
|
313
|
+
try {
|
|
314
|
+
const getRes = await safeFetch(inputUrl, {
|
|
315
|
+
method: 'GET',
|
|
316
|
+
headers: {
|
|
317
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
318
|
+
'Range': 'bytes=0-512'
|
|
319
|
+
},
|
|
320
|
+
signal: controller.signal
|
|
321
|
+
});
|
|
322
|
+
if (getRes.status >= 200 && getRes.status < 400) {
|
|
323
|
+
res = getRes;
|
|
324
|
+
}
|
|
325
|
+
} catch {
|
|
326
|
+
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
clearTimeout(timer);
|
|
331
|
+
|
|
332
|
+
if (res.status === 404 || res.status === 410) {
|
|
333
|
+
return { alive: false, status: res.status, reason: 'not_found' };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return { alive: true, status: res.status };
|
|
337
|
+
} catch (err) {
|
|
338
|
+
clearTimeout(timer);
|
|
339
|
+
const msg = String(err?.message || '');
|
|
340
|
+
if (
|
|
341
|
+
err?.code === 'ENOTFOUND' ||
|
|
342
|
+
err?.code === 'ECONNREFUSED' ||
|
|
343
|
+
msg.includes('getaddrinfo') ||
|
|
344
|
+
msg.includes('Unable to resolve hostname') ||
|
|
345
|
+
msg.includes('ENOTFOUND')
|
|
346
|
+
) {
|
|
347
|
+
return { alive: false, reason: err?.code || 'dns_failed' };
|
|
348
|
+
}
|
|
349
|
+
return { alive: true, unverified: true, reason: msg };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function readResponseBytesLimited(response, maxBytes) {
|
|
354
|
+
const limit = Math.max(1024, Math.min(Number(maxBytes) || 1024, 5_000_000));
|
|
355
|
+
let bytes;
|
|
356
|
+
if (response.body?.getReader) {
|
|
357
|
+
const reader = response.body.getReader();
|
|
358
|
+
const chunks = [];
|
|
359
|
+
let total = 0;
|
|
360
|
+
try {
|
|
361
|
+
while (true) {
|
|
362
|
+
const { done, value } = await reader.read();
|
|
363
|
+
if (done) break;
|
|
364
|
+
chunks.push(value);
|
|
365
|
+
total += value.byteLength;
|
|
366
|
+
if (total >= limit) break;
|
|
367
|
+
}
|
|
368
|
+
} finally {
|
|
369
|
+
try { reader.releaseLock(); } catch {}
|
|
370
|
+
}
|
|
371
|
+
bytes = Buffer.concat(chunks);
|
|
372
|
+
if (bytes.length > limit) bytes = bytes.subarray(0, limit);
|
|
373
|
+
} else if (typeof response.buffer === 'function') {
|
|
374
|
+
bytes = await response.buffer();
|
|
375
|
+
if (bytes.length > limit) bytes = bytes.subarray(0, limit);
|
|
376
|
+
} else if (typeof response.arrayBuffer === 'function') {
|
|
377
|
+
bytes = Buffer.from(await response.arrayBuffer());
|
|
378
|
+
if (bytes.length > limit) bytes = bytes.subarray(0, limit);
|
|
379
|
+
} else {
|
|
380
|
+
bytes = Buffer.from(await response.text(), 'utf8');
|
|
381
|
+
if (bytes.length > limit) bytes = bytes.subarray(0, limit);
|
|
109
382
|
}
|
|
383
|
+
return bytes;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function readResponseTextLimited(response, maxBytes) {
|
|
387
|
+
const bytes = await readResponseBytesLimited(response, maxBytes);
|
|
388
|
+
const contentType = response.headers?.get?.('content-type') || '';
|
|
389
|
+
return decodeHtmlBytes(bytes, contentType).text;
|
|
110
390
|
}
|
|
111
391
|
|
|
112
392
|
function detectLanguage(query) {
|
|
@@ -143,6 +423,7 @@ function expandQuery(query, lang) {
|
|
|
143
423
|
|
|
144
424
|
async function searchDuckDuckGo(query, maxResults) {
|
|
145
425
|
const results = [];
|
|
426
|
+
const seenLinks = new Set();
|
|
146
427
|
try {
|
|
147
428
|
const controller = new AbortController();
|
|
148
429
|
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
|
@@ -161,28 +442,60 @@ async function searchDuckDuckGo(query, maxResults) {
|
|
|
161
442
|
|
|
162
443
|
if (res.ok) {
|
|
163
444
|
const html = await res.text();
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
445
|
+
const cheerioLoad = await loadCheerio();
|
|
446
|
+
|
|
447
|
+
if (cheerioLoad) {
|
|
448
|
+
const $ = cheerioLoad(html);
|
|
449
|
+
$('div.result, div.web-result').each((_, el) => {
|
|
450
|
+
if (results.length >= maxResults) return;
|
|
451
|
+
const $el = $(el);
|
|
452
|
+
const anchor = $el.find('a.result__a').first();
|
|
453
|
+
let rawLink = anchor.attr('href') || '';
|
|
454
|
+
if (rawLink.includes('uddg=')) {
|
|
455
|
+
try {
|
|
456
|
+
const decoded = new URL('https://duckduckgo.com' + rawLink).searchParams.get('uddg');
|
|
457
|
+
if (decoded) rawLink = decoded;
|
|
458
|
+
} catch {}
|
|
459
|
+
}
|
|
460
|
+
const title = normalizeText(decodeEntities(anchor.text()));
|
|
461
|
+
const snippet = normalizeText(decodeEntities($el.find('.result__snippet').first().text()));
|
|
462
|
+
|
|
463
|
+
if (rawLink && title && !seenLinks.has(rawLink)) {
|
|
464
|
+
seenLinks.add(rawLink);
|
|
465
|
+
results.push({
|
|
466
|
+
title,
|
|
467
|
+
url: rawLink,
|
|
468
|
+
snippet,
|
|
469
|
+
source: 'DuckDuckGo'
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
}
|
|
175
474
|
|
|
176
|
-
|
|
177
|
-
const
|
|
475
|
+
if (results.length === 0) {
|
|
476
|
+
const resultRegex = /<div[^>]*class="[^"]*result[^"]*"[^>]*>[\s\S]*?<a[^>]+class="[^"]*result__a[^"]*"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<a[^>]+class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
477
|
+
let match;
|
|
478
|
+
while ((match = resultRegex.exec(html)) !== null && results.length < maxResults) {
|
|
479
|
+
let rawLink = match[1];
|
|
480
|
+
if (rawLink.includes('uddg=')) {
|
|
481
|
+
try {
|
|
482
|
+
const decoded = new URL('https://duckduckgo.com' + rawLink).searchParams.get('uddg');
|
|
483
|
+
if (decoded) rawLink = decoded;
|
|
484
|
+
} catch {}
|
|
485
|
+
}
|
|
178
486
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
487
|
+
const title = cleanHtml(match[2]);
|
|
488
|
+
const snippet = cleanHtml(match[3]);
|
|
489
|
+
|
|
490
|
+
if (title && snippet && !seenLinks.has(rawLink)) {
|
|
491
|
+
seenLinks.add(rawLink);
|
|
492
|
+
results.push({
|
|
493
|
+
title,
|
|
494
|
+
url: rawLink,
|
|
495
|
+
snippet,
|
|
496
|
+
source: 'DuckDuckGo'
|
|
497
|
+
});
|
|
498
|
+
}
|
|
186
499
|
}
|
|
187
500
|
}
|
|
188
501
|
}
|
|
@@ -226,18 +539,21 @@ async function searchWikipedia(query, maxResults, lang) {
|
|
|
226
539
|
return results;
|
|
227
540
|
}
|
|
228
541
|
|
|
229
|
-
async function searchBing(query, maxResults) {
|
|
542
|
+
async function searchBing(query, maxResults, { language } = {}) {
|
|
230
543
|
const results = [];
|
|
544
|
+
const seenLinks = new Set();
|
|
231
545
|
try {
|
|
546
|
+
const lang = language || detectLanguage(query);
|
|
547
|
+
const setlang = lang === 'th' ? 'th' : lang === 'zh' ? 'zh-cn' : 'en-us';
|
|
232
548
|
const controller = new AbortController();
|
|
233
549
|
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
|
234
550
|
|
|
235
|
-
const searchUrl = `https://www.bing.com/search?q=${encodeURIComponent(query)}&setlang=${
|
|
551
|
+
const searchUrl = `https://www.bing.com/search?q=${encodeURIComponent(query)}&setlang=${setlang}`;
|
|
236
552
|
const res = await fetch(searchUrl, {
|
|
237
553
|
headers: {
|
|
238
|
-
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
554
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
239
555
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
240
|
-
'Accept-Language': '
|
|
556
|
+
'Accept-Language': 'zh-CN,zh;q=0.9,th;q=0.8,en;q=0.7'
|
|
241
557
|
},
|
|
242
558
|
signal: controller.signal
|
|
243
559
|
});
|
|
@@ -245,101 +561,278 @@ async function searchBing(query, maxResults) {
|
|
|
245
561
|
clearTimeout(timeoutId);
|
|
246
562
|
|
|
247
563
|
if (res.ok) {
|
|
248
|
-
const html = await res
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
if (
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
564
|
+
const html = await readResponseTextLimited(res, 2_000_000);
|
|
565
|
+
const algoRegex = /<li[^>]*class="[^"]*b_algo[^"]*"[^>]*>([\s\S]*?)<\/li>/gi;
|
|
566
|
+
let blockMatch;
|
|
567
|
+
while ((blockMatch = algoRegex.exec(html)) !== null && results.length < maxResults) {
|
|
568
|
+
const block = blockMatch[1];
|
|
569
|
+
const aMatch = block.match(/<h2[^>]*>\s*<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>\s*<\/h2>/i);
|
|
570
|
+
if (!aMatch) continue;
|
|
571
|
+
const link = aMatch[1];
|
|
572
|
+
const title = cleanHtml(aMatch[2]);
|
|
573
|
+
if (!link || !title || seenLinks.has(link)) continue;
|
|
574
|
+
|
|
575
|
+
let snippet = '';
|
|
576
|
+
const pMatch = block.match(/<p[^>]*>([\s\S]*?)<\/p>/i);
|
|
577
|
+
if (pMatch) {
|
|
578
|
+
snippet = cleanHtml(pMatch[1]).slice(0, 320);
|
|
579
|
+
} else {
|
|
580
|
+
const capMatch = block.match(/<div[^>]*class="[^"]*b_caption[^"]*"[^>]*>([\s\S]*?)<\/div>/i);
|
|
581
|
+
if (capMatch) snippet = cleanHtml(capMatch[1]).slice(0, 320);
|
|
582
|
+
}
|
|
583
|
+
seenLinks.add(link);
|
|
584
|
+
results.push({ title, url: link, snippet, source: 'Bing' });
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (results.length === 0) {
|
|
588
|
+
const linkRegex = /<h2><a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<p[^>]*class="b_lineclamp2 b_algoSlug"[^>]*>([\s\S]*?)<\/p>/gi;
|
|
589
|
+
let match;
|
|
590
|
+
while ((match = linkRegex.exec(html)) !== null && results.length < maxResults) {
|
|
591
|
+
const rawLink = match[1];
|
|
592
|
+
const title = cleanHtml(match[2]);
|
|
593
|
+
const snippet = cleanHtml(match[3]);
|
|
594
|
+
if (title && snippet && !seenLinks.has(rawLink)) {
|
|
595
|
+
seenLinks.add(rawLink);
|
|
596
|
+
results.push({ title, url: rawLink, snippet, source: 'Bing' });
|
|
597
|
+
}
|
|
264
598
|
}
|
|
265
599
|
}
|
|
266
600
|
}
|
|
267
|
-
} catch
|
|
268
|
-
|
|
269
|
-
|
|
601
|
+
} catch {}
|
|
602
|
+
|
|
270
603
|
return results;
|
|
271
604
|
}
|
|
272
605
|
|
|
273
|
-
export async function searchWeb(query, maxResults = 8) {
|
|
606
|
+
export async function searchWeb(query, maxResults = 8, options = {}) {
|
|
274
607
|
if (!query || typeof query !== 'string') {
|
|
275
608
|
return { status: 'error', message: 'Query string is required for web search.' };
|
|
276
609
|
}
|
|
277
610
|
|
|
278
611
|
const lang = detectLanguage(query);
|
|
279
|
-
const
|
|
280
|
-
const
|
|
612
|
+
const capped = Math.min(Math.max(maxResults, 1), 25);
|
|
613
|
+
const cacheKey = computeSearchCacheKey('searchWeb', query, capped);
|
|
614
|
+
|
|
615
|
+
if (!options.noCache) {
|
|
616
|
+
const cached = defaultMetaSearch.cache.get(cacheKey);
|
|
617
|
+
if (cached) {
|
|
618
|
+
return { ...cached, fromCache: true };
|
|
619
|
+
}
|
|
620
|
+
}
|
|
281
621
|
|
|
282
622
|
try {
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
623
|
+
const [metaResSettled, imgResSettled] = await Promise.allSettled([
|
|
624
|
+
defaultMetaSearch.executeSearch(query, {
|
|
625
|
+
limit: capped,
|
|
626
|
+
language: lang,
|
|
627
|
+
...options
|
|
628
|
+
}),
|
|
629
|
+
searchWebImages(query, 6).catch(() => null)
|
|
630
|
+
]);
|
|
631
|
+
|
|
632
|
+
const metaRes = metaResSettled.status === 'fulfilled' ? metaResSettled.value : null;
|
|
633
|
+
const imgRes = imgResSettled.status === 'fulfilled' ? imgResSettled.value : null;
|
|
634
|
+
const images = Array.isArray(imgRes?.results) ? imgRes.results.slice(0, 6) : [];
|
|
635
|
+
|
|
636
|
+
if (metaRes && metaRes.results && metaRes.results.length > 0) {
|
|
637
|
+
const sources = [...new Set(metaRes.results.map(r => r.engine || metaRes.engine || 'web'))];
|
|
638
|
+
const resultObj = {
|
|
639
|
+
status: 'success',
|
|
640
|
+
query,
|
|
641
|
+
language: lang,
|
|
642
|
+
count: metaRes.results.length,
|
|
643
|
+
sources,
|
|
644
|
+
results: metaRes.results.map(r => ({
|
|
645
|
+
title: r.title,
|
|
646
|
+
url: r.link,
|
|
647
|
+
snippet: r.snippet,
|
|
648
|
+
source: r.engine || 'web'
|
|
649
|
+
})),
|
|
650
|
+
...(images.length > 0 ? { images } : {}),
|
|
651
|
+
...(metaRes.mode === 'deep_scrape' ? {
|
|
652
|
+
scrapedPages: metaRes.scrapedPages,
|
|
653
|
+
combinedContent: metaRes.combinedContent
|
|
654
|
+
} : {})
|
|
655
|
+
};
|
|
656
|
+
if (!options.noCache) {
|
|
657
|
+
defaultMetaSearch.cache.set(cacheKey, resultObj, TTL_GENERAL_MS);
|
|
288
658
|
}
|
|
289
|
-
|
|
659
|
+
return resultObj;
|
|
660
|
+
}
|
|
661
|
+
} catch {}
|
|
662
|
+
|
|
663
|
+
try {
|
|
664
|
+
const wikiItems = await searchWikipedia(query, Math.min(capped, 3), lang);
|
|
665
|
+
if (wikiItems.length > 0) {
|
|
666
|
+
const wikiResponse = {
|
|
667
|
+
status: 'success',
|
|
668
|
+
query,
|
|
669
|
+
language: lang,
|
|
670
|
+
count: wikiItems.length,
|
|
671
|
+
sources: ['Wikipedia'],
|
|
672
|
+
results: wikiItems
|
|
673
|
+
};
|
|
674
|
+
if (!options.noCache) {
|
|
675
|
+
defaultMetaSearch.cache.set(cacheKey, wikiResponse, TTL_GENERAL_MS);
|
|
676
|
+
}
|
|
677
|
+
return wikiResponse;
|
|
678
|
+
}
|
|
290
679
|
} catch {}
|
|
291
680
|
|
|
292
|
-
|
|
681
|
+
return {
|
|
682
|
+
status: 'no_results',
|
|
683
|
+
query,
|
|
684
|
+
language: lang,
|
|
685
|
+
count: 0,
|
|
686
|
+
sources: [],
|
|
687
|
+
results: []
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const PAGE_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
692
|
+
const pageContentCache = new BoundedLruCache(40);
|
|
693
|
+
|
|
694
|
+
function buildPageResult(pageData, { finalUrl, maxLength, query, cached }) {
|
|
695
|
+
const markdown = pageData.markdown || '';
|
|
696
|
+
let content = markdown;
|
|
697
|
+
let contentMode = 'full';
|
|
698
|
+
const totalChars = markdown.length;
|
|
699
|
+
|
|
700
|
+
if (query) {
|
|
701
|
+
const passages = splitIntoPassages(markdown);
|
|
702
|
+
if (passages.length > 1 && totalChars > maxLength) {
|
|
703
|
+
const selection = selectPassages(passages, query, { budgetChars: maxLength });
|
|
704
|
+
if (selection.text && selection.selected < selection.total) {
|
|
705
|
+
content = `[Excerpt: ${selection.selected}/${selection.total} most relevant sections]\n\n${selection.text}`;
|
|
706
|
+
contentMode = 'query-passages';
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (contentMode === 'full' && totalChars > maxLength) {
|
|
711
|
+
content = truncateByWords(markdown, maxLength) + '\n\n... [Content truncated for brevity]';
|
|
712
|
+
contentMode = 'truncated';
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const result = {
|
|
716
|
+
status: 'success',
|
|
717
|
+
url: finalUrl,
|
|
718
|
+
contentType: 'html',
|
|
719
|
+
contentLength: content.length,
|
|
720
|
+
totalPageChars: totalChars,
|
|
721
|
+
content: content || 'Unable to retrieve text content from this webpage.',
|
|
722
|
+
meta: pageData.meta || {},
|
|
723
|
+
extraction: {
|
|
724
|
+
engine: pageData.engine || 'cheerio-readability',
|
|
725
|
+
mode: contentMode,
|
|
726
|
+
cached: Boolean(cached),
|
|
727
|
+
charset: pageData.charset || 'utf-8',
|
|
728
|
+
quality: pageData.quality || null
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
if (pageData.headings?.length > 0) result.headings = pageData.headings;
|
|
733
|
+
if (pageData.paragraphs?.length > 0) result.paragraphs = pageData.paragraphs;
|
|
734
|
+
if (pageData.links?.length > 0) result.links = pageData.links;
|
|
735
|
+
if (pageData.images?.length > 0) result.images = pageData.images;
|
|
736
|
+
if (pageData.jsonLd) result.jsonLd = pageData.jsonLd;
|
|
737
|
+
|
|
738
|
+
return result;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async function rescueThinPage({ url, markdown, jsonLd }) {
|
|
742
|
+
const candidates = [];
|
|
743
|
+
if (jsonLd?.body && jsonLd.body.length > markdown.length && jsonLd.body.length > 400) {
|
|
744
|
+
candidates.push({ engine: 'jsonld-articlebody', markdown: jsonLd.body });
|
|
745
|
+
}
|
|
746
|
+
if (markdown.length < 400) {
|
|
293
747
|
try {
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
748
|
+
const controller = new AbortController();
|
|
749
|
+
const timeoutId = setTimeout(() => controller.abort(), 12000);
|
|
750
|
+
const res = await safeFetch(`https://r.jina.ai/${url}`, {
|
|
751
|
+
headers: {
|
|
752
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
753
|
+
'Accept': 'text/plain',
|
|
754
|
+
'X-No-Cache': 'false'
|
|
755
|
+
},
|
|
756
|
+
signal: controller.signal
|
|
300
757
|
});
|
|
758
|
+
clearTimeout(timeoutId);
|
|
759
|
+
if (res.ok) {
|
|
760
|
+
const text = (await readResponseTextLimited(res, 300_000)).trim();
|
|
761
|
+
if (text.length > 400) candidates.push({ engine: 'jina-reader', markdown: text });
|
|
762
|
+
}
|
|
301
763
|
} catch {}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
if (results.length < maxResults / 2) {
|
|
305
764
|
try {
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
results.push(r);
|
|
313
|
-
sources.push('DuckDuckGo (expanded)');
|
|
314
|
-
}
|
|
315
|
-
});
|
|
765
|
+
const renderedDom = await chromeDumpDom(url);
|
|
766
|
+
if (renderedDom) {
|
|
767
|
+
const rendered = await extractMainContent(renderedDom, { url });
|
|
768
|
+
if (rendered.markdown && rendered.markdown.length > 400 && !rendered.quality?.isThin) {
|
|
769
|
+
candidates.push({ engine: 'chrome-render', markdown: rendered.markdown });
|
|
770
|
+
}
|
|
316
771
|
}
|
|
317
772
|
} catch {}
|
|
318
773
|
}
|
|
774
|
+
candidates.sort((a, b) => b.markdown.length - a.markdown.length);
|
|
775
|
+
return candidates[0] || null;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async function chromeDumpDom(url, timeoutMs = 20000) {
|
|
779
|
+
if (process.env.KIKU_DISABLE_CHROME_EXTRACT === 'true') return null;
|
|
780
|
+
const chromePath = findChromePath();
|
|
781
|
+
if (!chromePath) return null;
|
|
782
|
+
|
|
783
|
+
let releaseLock = null;
|
|
784
|
+
let tmpDir = null;
|
|
785
|
+
try {
|
|
786
|
+
releaseLock = await acquireChromeLock(timeoutMs);
|
|
787
|
+
} catch {
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
319
790
|
|
|
320
|
-
|
|
791
|
+
try {
|
|
792
|
+
let targetUrl = url;
|
|
321
793
|
try {
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
if (!results.some(existing => existing.url === r.url)) {
|
|
325
|
-
results.push(r);
|
|
326
|
-
sources.push('Bing');
|
|
327
|
-
}
|
|
328
|
-
});
|
|
794
|
+
const preflight = await safeFetch(url, { method: 'HEAD', timeout: 5000 });
|
|
795
|
+
if (preflight?.url) targetUrl = preflight.url;
|
|
329
796
|
} catch {}
|
|
330
|
-
}
|
|
331
797
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
798
|
+
const targetHostname = new URL(targetUrl).hostname;
|
|
799
|
+
tmpDir = await mkdtemp(join(tmpdir(), 'kiku-dom-'));
|
|
800
|
+
const args = [
|
|
801
|
+
'--headless=new',
|
|
802
|
+
'--disable-gpu',
|
|
803
|
+
'--disable-dev-shm-usage',
|
|
804
|
+
'--disable-software-rasterizer',
|
|
805
|
+
'--disable-background-networking',
|
|
806
|
+
'--disable-sync',
|
|
807
|
+
'--no-first-run',
|
|
808
|
+
'--no-default-browser-check',
|
|
809
|
+
`--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE ${targetHostname}`,
|
|
810
|
+
`--user-data-dir=${join(tmpDir, 'chrome-profile')}`,
|
|
811
|
+
'--virtual-time-budget=8000',
|
|
812
|
+
'--dump-dom',
|
|
813
|
+
targetUrl
|
|
814
|
+
];
|
|
815
|
+
|
|
816
|
+
const stdout = await new Promise((resolve, reject) => {
|
|
817
|
+
const child = execFile(chromePath, args, { timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024 }, (err, stdout) => {
|
|
818
|
+
if (err) reject(err);
|
|
819
|
+
else resolve(stdout || '');
|
|
820
|
+
});
|
|
821
|
+
child.on('error', reject);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
return stdout.length > 500 ? stdout : null;
|
|
825
|
+
} catch {
|
|
826
|
+
return null;
|
|
827
|
+
} finally {
|
|
828
|
+
if (tmpDir) {
|
|
829
|
+
try { await rm(tmpDir, { recursive: true, force: true }); } catch {}
|
|
830
|
+
}
|
|
831
|
+
if (releaseLock) releaseLock();
|
|
832
|
+
}
|
|
340
833
|
}
|
|
341
834
|
|
|
342
|
-
export async function fetchWebpage(url, maxLength = 8000) {
|
|
835
|
+
export async function fetchWebpage(url, maxLength = 8000, options = {}) {
|
|
343
836
|
if (!url || typeof url !== 'string' || !url.startsWith('http')) {
|
|
344
837
|
return { status: 'error', message: 'Valid HTTP/HTTPS URL is required.' };
|
|
345
838
|
}
|
|
@@ -355,22 +848,40 @@ export async function fetchWebpage(url, maxLength = 8000) {
|
|
|
355
848
|
return { status: 'error', message: `URL rejected by SSRF guard: ${ssrf.reason}` };
|
|
356
849
|
}
|
|
357
850
|
|
|
851
|
+
const query = typeof options.query === 'string' ? options.query : '';
|
|
852
|
+
const cacheKey = `${parsedUrl.origin}${parsedUrl.pathname}${parsedUrl.search}`;
|
|
853
|
+
const cached = pageContentCache.get(cacheKey);
|
|
854
|
+
|
|
358
855
|
try {
|
|
359
856
|
const controller = new AbortController();
|
|
360
857
|
const timeoutId = setTimeout(() => controller.abort(), 15000);
|
|
361
858
|
|
|
859
|
+
const headers = {
|
|
860
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
861
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
862
|
+
'Accept-Language': 'en-US,en;q=0.9,th;q=0.8,zh-CN;q=0.7'
|
|
863
|
+
};
|
|
864
|
+
if (cached) {
|
|
865
|
+
if (cached.etag) headers['If-None-Match'] = cached.etag;
|
|
866
|
+
if (cached.lastModified) headers['If-Modified-Since'] = cached.lastModified;
|
|
867
|
+
}
|
|
868
|
+
|
|
362
869
|
const res = await safeFetch(url, {
|
|
363
|
-
headers
|
|
364
|
-
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
365
|
-
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
366
|
-
'Accept-Language': 'en-US,en;q=0.9,th;q=0.8,zh-CN;q=0.7'
|
|
367
|
-
},
|
|
870
|
+
headers,
|
|
368
871
|
signal: controller.signal
|
|
369
872
|
});
|
|
370
873
|
|
|
371
874
|
clearTimeout(timeoutId);
|
|
372
875
|
|
|
876
|
+
if (cached && res.status === 304) {
|
|
877
|
+
pageContentCache.set(cacheKey, { ...cached, cachedAt: Date.now() }, PAGE_CACHE_TTL_MS);
|
|
878
|
+
return buildPageResult(cached, { finalUrl: cached.finalUrl || url, maxLength, query, cached: true });
|
|
879
|
+
}
|
|
880
|
+
|
|
373
881
|
if (!res.ok) {
|
|
882
|
+
if (cached) {
|
|
883
|
+
return buildPageResult(cached, { finalUrl: cached.finalUrl || url, maxLength, query, cached: true });
|
|
884
|
+
}
|
|
374
885
|
return { status: 'error', message: `HTTP ${res.status}: Failed to load webpage` };
|
|
375
886
|
}
|
|
376
887
|
|
|
@@ -390,111 +901,50 @@ export async function fetchWebpage(url, maxLength = 8000) {
|
|
|
390
901
|
};
|
|
391
902
|
}
|
|
392
903
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
if (cheerioLoad) {
|
|
397
|
-
const $ = cheerioLoad(html);
|
|
398
|
-
|
|
399
|
-
$('script, style, noscript, iframe, svg, nav, footer, aside, form, button').remove();
|
|
400
|
-
|
|
401
|
-
const meta = {};
|
|
402
|
-
meta.title = $('title').first().text().trim() || '';
|
|
403
|
-
meta.description = $('meta[name="description"]').attr('content') || $('meta[property="og:description"]').attr('content') || '';
|
|
404
|
-
meta.author = $('meta[name="author"]').attr('content') || '';
|
|
405
|
-
meta.keywords = $('meta[name="keywords"]').attr('content') || '';
|
|
406
|
-
meta.ogTitle = $('meta[property="og:title"]').attr('content') || '';
|
|
407
|
-
meta.ogImage = $('meta[property="og:image"]').attr('content') || '';
|
|
408
|
-
meta.ogType = $('meta[property="og:type"]').attr('content') || '';
|
|
409
|
-
meta.canonical = $('link[rel="canonical"]').attr('href') || '';
|
|
410
|
-
meta.favicon = $('link[rel="icon"], link[rel="shortcut icon"]').attr('href') || '';
|
|
411
|
-
meta.lang = $('html').attr('lang') || '';
|
|
412
|
-
|
|
413
|
-
const headings = [];
|
|
414
|
-
$('h1, h2, h3').each((i, el) => {
|
|
415
|
-
if (i < 10) {
|
|
416
|
-
const txt = $(el).text().trim();
|
|
417
|
-
if (txt) headings.push({ level: el.tagName, text: txt });
|
|
418
|
-
}
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
const paragraphs = [];
|
|
422
|
-
$('article p, main p, .content p, .post-content p, .entry-content p, .article-body p, .wiki-content p').each((i, el) => {
|
|
423
|
-
if (i < 20) {
|
|
424
|
-
const txt = $(el).text().trim();
|
|
425
|
-
if (txt && txt.length > 20) paragraphs.push(txt);
|
|
426
|
-
}
|
|
427
|
-
});
|
|
428
|
-
if (paragraphs.length === 0) {
|
|
429
|
-
$('p').each((i, el) => {
|
|
430
|
-
if (i < 15) {
|
|
431
|
-
const txt = $(el).text().trim();
|
|
432
|
-
if (txt && txt.length > 30) paragraphs.push(txt);
|
|
433
|
-
}
|
|
434
|
-
});
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
const links = [];
|
|
438
|
-
$('article a, main a, .content a').each((i, el) => {
|
|
439
|
-
if (i < 15) {
|
|
440
|
-
const href = $(el).attr('href');
|
|
441
|
-
const text = $(el).text().trim();
|
|
442
|
-
if (href && text && text.length > 2) {
|
|
443
|
-
let absoluteUrl = href;
|
|
444
|
-
try { absoluteUrl = new URL(href, finalUrl).href; } catch {}
|
|
445
|
-
links.push({ text: text.slice(0, 80), url: absoluteUrl });
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
});
|
|
449
|
-
|
|
450
|
-
const images = [];
|
|
451
|
-
$('article img, main img, .content img').each((i, el) => {
|
|
452
|
-
if (i < 8) {
|
|
453
|
-
const src = $(el).attr('src');
|
|
454
|
-
const alt = $(el).attr('alt') || '';
|
|
455
|
-
if (src) {
|
|
456
|
-
let absoluteUrl = src;
|
|
457
|
-
try { absoluteUrl = new URL(src, finalUrl).href; } catch {}
|
|
458
|
-
images.push({ src: absoluteUrl, alt: alt.slice(0, 100) });
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
let bodyText = $('body').text().replace(/\s+/g, ' ').trim();
|
|
464
|
-
if (bodyText.length > maxLength) {
|
|
465
|
-
bodyText = bodyText.slice(0, maxLength) + '... [Content truncated for brevity]';
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
const result = {
|
|
469
|
-
status: 'success',
|
|
470
|
-
url: finalUrl,
|
|
471
|
-
contentType: 'html',
|
|
472
|
-
contentLength: bodyText.length,
|
|
473
|
-
content: bodyText || 'Unable to retrieve text content.',
|
|
474
|
-
meta: Object.fromEntries(Object.entries(meta).filter(([, v]) => v))
|
|
475
|
-
};
|
|
476
|
-
|
|
477
|
-
if (headings.length > 0) result.headings = headings;
|
|
478
|
-
if (paragraphs.length > 0) result.paragraphs = paragraphs;
|
|
479
|
-
if (links.length > 0) result.links = links;
|
|
480
|
-
if (images.length > 0) result.images = images;
|
|
481
|
-
|
|
482
|
-
return result;
|
|
904
|
+
if (/^(image|audio|video)\//.test(contentType) || contentType.includes('application/pdf')) {
|
|
905
|
+
return { status: 'error', url: finalUrl, message: `Unsupported content type: ${contentType}` };
|
|
483
906
|
}
|
|
484
907
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
908
|
+
const htmlBytes = await readResponseBytesLimited(res, Math.max(256_000, maxLength * 16));
|
|
909
|
+
const decoded = decodeHtmlBytes(htmlBytes, contentType);
|
|
910
|
+
const extraction = await extractMainContent(decoded.text, { url: finalUrl });
|
|
911
|
+
|
|
912
|
+
let engine = 'cheerio-readability';
|
|
913
|
+
let markdown = extraction.markdown;
|
|
914
|
+
if (extraction.quality?.isThin) {
|
|
915
|
+
const rescue = await rescueThinPage({ url: finalUrl, markdown, jsonLd: extraction.jsonLd });
|
|
916
|
+
if (rescue && rescue.markdown.length > markdown.length * 1.3) {
|
|
917
|
+
markdown = rescue.markdown;
|
|
918
|
+
engine = rescue.engine;
|
|
919
|
+
}
|
|
488
920
|
}
|
|
489
921
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
922
|
+
const pageData = {
|
|
923
|
+
markdown,
|
|
924
|
+
meta: extraction.meta,
|
|
925
|
+
headings: extraction.headings,
|
|
926
|
+
paragraphs: extraction.paragraphs,
|
|
927
|
+
links: extraction.links,
|
|
928
|
+
images: extraction.images,
|
|
929
|
+
jsonLd: extraction.jsonLd,
|
|
930
|
+
quality: extraction.quality,
|
|
931
|
+
engine,
|
|
932
|
+
charset: decoded.charset,
|
|
933
|
+
finalUrl
|
|
496
934
|
};
|
|
935
|
+
|
|
936
|
+
pageContentCache.set(cacheKey, {
|
|
937
|
+
...pageData,
|
|
938
|
+
etag: res.headers.get('etag') || '',
|
|
939
|
+
lastModified: res.headers.get('last-modified') || '',
|
|
940
|
+
cachedAt: Date.now()
|
|
941
|
+
}, PAGE_CACHE_TTL_MS);
|
|
942
|
+
|
|
943
|
+
return buildPageResult(pageData, { finalUrl, maxLength, query, cached: false });
|
|
497
944
|
} catch (err) {
|
|
945
|
+
if (cached) {
|
|
946
|
+
return buildPageResult(cached, { finalUrl: cached.finalUrl || url, maxLength, query, cached: true });
|
|
947
|
+
}
|
|
498
948
|
return {
|
|
499
949
|
status: 'error',
|
|
500
950
|
url,
|
|
@@ -650,17 +1100,38 @@ export async function screenshotWebpage(url, options = {}) {
|
|
|
650
1100
|
return { status: 'error', message: 'Google Chrome not found. Install Chrome to enable screenshot feature.' };
|
|
651
1101
|
}
|
|
652
1102
|
|
|
653
|
-
|
|
654
|
-
|
|
1103
|
+
let releaseLock = null;
|
|
1104
|
+
try {
|
|
1105
|
+
releaseLock = await acquireChromeLock(timeoutMs);
|
|
1106
|
+
} catch (lockErr) {
|
|
1107
|
+
return { status: 'error', message: lockErr.message };
|
|
1108
|
+
}
|
|
655
1109
|
|
|
1110
|
+
let tmpDir = null;
|
|
656
1111
|
try {
|
|
1112
|
+
|
|
1113
|
+
let targetUrl = url;
|
|
1114
|
+
try {
|
|
1115
|
+
const preflight = await safeFetch(url, { method: 'HEAD', timeout: 5000 });
|
|
1116
|
+
if (preflight?.url) targetUrl = preflight.url;
|
|
1117
|
+
} catch {}
|
|
1118
|
+
|
|
1119
|
+
const targetHostname = new URL(targetUrl).hostname;
|
|
1120
|
+
tmpDir = await mkdtemp(join(tmpdir(), 'kiku-screenshot-'));
|
|
1121
|
+
const profileDir = join(tmpDir, 'chrome-profile');
|
|
657
1122
|
const screenshotFile = 'kiku_screenshot.png';
|
|
658
1123
|
const screenshotPath = join(tmpDir, screenshotFile);
|
|
1124
|
+
|
|
659
1125
|
const args = [
|
|
660
1126
|
'--headless=new',
|
|
661
1127
|
'--disable-gpu',
|
|
662
1128
|
'--disable-dev-shm-usage',
|
|
663
1129
|
'--disable-software-rasterizer',
|
|
1130
|
+
'--disable-background-networking',
|
|
1131
|
+
'--disable-sync',
|
|
1132
|
+
'--no-first-run',
|
|
1133
|
+
'--no-default-browser-check',
|
|
1134
|
+
`--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE ${targetHostname}`,
|
|
664
1135
|
`--user-data-dir=${profileDir}`,
|
|
665
1136
|
`--window-size=${width},${height}`,
|
|
666
1137
|
`--screenshot=${screenshotPath}`,
|
|
@@ -668,7 +1139,7 @@ export async function screenshotWebpage(url, options = {}) {
|
|
|
668
1139
|
'--virtual-time-budget=8000'
|
|
669
1140
|
];
|
|
670
1141
|
|
|
671
|
-
args.push(
|
|
1142
|
+
args.push(targetUrl);
|
|
672
1143
|
|
|
673
1144
|
await new Promise((resolve, reject) => {
|
|
674
1145
|
const child = execFile(chromePath, args, {
|
|
@@ -690,7 +1161,7 @@ export async function screenshotWebpage(url, options = {}) {
|
|
|
690
1161
|
|
|
691
1162
|
return {
|
|
692
1163
|
status: 'success',
|
|
693
|
-
url,
|
|
1164
|
+
url: targetUrl,
|
|
694
1165
|
width,
|
|
695
1166
|
height,
|
|
696
1167
|
format: 'png',
|
|
@@ -705,12 +1176,1538 @@ export async function screenshotWebpage(url, options = {}) {
|
|
|
705
1176
|
message: `Screenshot failed: ${err.message}`
|
|
706
1177
|
};
|
|
707
1178
|
} finally {
|
|
708
|
-
|
|
1179
|
+
if (tmpDir) {
|
|
1180
|
+
try { await rm(tmpDir, { recursive: true, force: true }); } catch {}
|
|
1181
|
+
}
|
|
1182
|
+
if (releaseLock) {
|
|
1183
|
+
releaseLock();
|
|
1184
|
+
}
|
|
709
1185
|
}
|
|
710
1186
|
}
|
|
711
1187
|
|
|
712
|
-
export
|
|
713
|
-
|
|
1188
|
+
export { detectLanguage };
|
|
1189
|
+
|
|
1190
|
+
async function fetchWithTimeout(url, options = {}, timeoutMs = 10000) {
|
|
1191
|
+
const controller = new AbortController();
|
|
1192
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
1193
|
+
try {
|
|
1194
|
+
return await safeFetch(url, { ...options, signal: controller.signal });
|
|
1195
|
+
} finally {
|
|
1196
|
+
clearTimeout(timeoutId);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function stripCdata(value) {
|
|
1201
|
+
return String(value || '')
|
|
1202
|
+
.replace(/^\s*<!\[CDATA\[/, '')
|
|
1203
|
+
.replace(/\]\]>\s*$/, '')
|
|
1204
|
+
.trim();
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
export function parseRssFeed(xml) {
|
|
1208
|
+
const text = String(xml || '');
|
|
1209
|
+
const items = [];
|
|
1210
|
+
const blocks = text.match(/<(?:item|entry)[\s\S]*?<\/(?:item|entry)>/gi) || [];
|
|
1211
|
+
|
|
1212
|
+
for (const block of blocks) {
|
|
1213
|
+
const pick = (tags) => {
|
|
1214
|
+
for (const tag of tags) {
|
|
1215
|
+
const m = block.match(new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)</${tag}>`, 'i'));
|
|
1216
|
+
if (m) return stripCdata(m[1]);
|
|
1217
|
+
}
|
|
1218
|
+
return '';
|
|
1219
|
+
};
|
|
1220
|
+
|
|
1221
|
+
let link = '';
|
|
1222
|
+
const hrefMatch = block.match(/<link[^>]*\shref="([^"]+)"/i);
|
|
1223
|
+
if (hrefMatch) {
|
|
1224
|
+
link = stripCdata(hrefMatch[1]);
|
|
1225
|
+
} else {
|
|
1226
|
+
link = pick(['link']);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const title = cleanHtml(pick(['title']));
|
|
1230
|
+
const snippet = cleanHtml(pick(['description', 'summary', 'content'])).slice(0, 400);
|
|
1231
|
+
const publishedAt = pick(['pubDate', 'published', 'updated', 'dc:date']);
|
|
1232
|
+
const sourceName = cleanHtml(pick(['source'])).slice(0, 80);
|
|
1233
|
+
|
|
1234
|
+
if (title && link) {
|
|
1235
|
+
items.push({ title, url: link, snippet, publishedAt, source: sourceName || '' });
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
return items;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const NEWS_EDITIONS = {
|
|
1243
|
+
zh: { hl: 'zh-CN', gl: 'CN', ceid: 'CN:zh-Hans', mkt: 'zh-CN' },
|
|
1244
|
+
th: { hl: 'th', gl: 'TH', ceid: 'TH:th', mkt: 'th-TH' },
|
|
1245
|
+
en: { hl: 'en-US', gl: 'US', ceid: 'US:en', mkt: 'en-US' }
|
|
1246
|
+
};
|
|
1247
|
+
|
|
1248
|
+
const BROWSER_HEADERS = {
|
|
1249
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
1250
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml,application/rss+xml;q=0.9,*/*;q=0.8',
|
|
1251
|
+
'Accept-Language': 'th,en-US;q=0.9,en;q=0.8,zh-CN;q=0.7,zh;q=0.6'
|
|
1252
|
+
};
|
|
1253
|
+
|
|
1254
|
+
async function fetchGoogleNewsRss(query, maxResults, lang) {
|
|
1255
|
+
const edition = NEWS_EDITIONS[lang] || NEWS_EDITIONS.en;
|
|
1256
|
+
const url = `https://news.google.com/rss/search?q=${encodeURIComponent(query)}&hl=${edition.hl}&gl=${edition.gl}&ceid=${encodeURIComponent(edition.ceid)}`;
|
|
1257
|
+
const res = await fetchWithTimeout(url, { headers: BROWSER_HEADERS }, 9000);
|
|
1258
|
+
if (!res.ok) throw new Error(`Google News RSS HTTP ${res.status}`);
|
|
1259
|
+
const xml = await readResponseTextLimited(res, 512_000);
|
|
1260
|
+
return parseRssFeed(xml)
|
|
1261
|
+
.slice(0, maxResults)
|
|
1262
|
+
.map(item => ({
|
|
1263
|
+
title: item.title,
|
|
1264
|
+
url: item.url,
|
|
1265
|
+
snippet: item.snippet,
|
|
1266
|
+
publishedAt: item.publishedAt,
|
|
1267
|
+
source: item.source || 'Google News'
|
|
1268
|
+
}));
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
async function fetchBingNewsRss(query, maxResults, lang) {
|
|
1272
|
+
const edition = NEWS_EDITIONS[lang] || NEWS_EDITIONS.en;
|
|
1273
|
+
const url = `https://www.bing.com/news/search?q=${encodeURIComponent(query)}&format=RSS&setmkt=${edition.mkt}`;
|
|
1274
|
+
const res = await fetchWithTimeout(url, { headers: BROWSER_HEADERS }, 9000);
|
|
1275
|
+
if (!res.ok) throw new Error(`Bing News RSS HTTP ${res.status}`);
|
|
1276
|
+
const xml = await readResponseTextLimited(res, 512_000);
|
|
1277
|
+
return parseRssFeed(xml)
|
|
1278
|
+
.slice(0, maxResults)
|
|
1279
|
+
.map(item => ({
|
|
1280
|
+
title: item.title,
|
|
1281
|
+
url: item.url,
|
|
1282
|
+
snippet: item.snippet,
|
|
1283
|
+
publishedAt: item.publishedAt,
|
|
1284
|
+
source: item.source || 'Bing News'
|
|
1285
|
+
}));
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
async function fetchYahooNewsRss(query, maxResults) {
|
|
1289
|
+
try {
|
|
1290
|
+
const url = `https://news.yahoo.com/rss?p=${encodeURIComponent(query)}`;
|
|
1291
|
+
const res = await fetchWithTimeout(url, { headers: BROWSER_HEADERS }, 8000);
|
|
1292
|
+
if (!res.ok) return [];
|
|
1293
|
+
const xml = await readResponseTextLimited(res, 512_000);
|
|
1294
|
+
return parseRssFeed(xml).slice(0, maxResults).map(item => ({
|
|
1295
|
+
title: item.title,
|
|
1296
|
+
url: item.url,
|
|
1297
|
+
snippet: item.snippet,
|
|
1298
|
+
publishedAt: item.publishedAt,
|
|
1299
|
+
source: item.source || 'Yahoo News'
|
|
1300
|
+
}));
|
|
1301
|
+
} catch {
|
|
1302
|
+
return [];
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
export async function searchNews(query, maxResults = 8) {
|
|
1307
|
+
if (!query || typeof query !== 'string') {
|
|
1308
|
+
return { status: 'error', message: 'Query string is required for news search.' };
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
const capped = Math.min(Math.max(maxResults, 1), 20);
|
|
1312
|
+
const lang = detectLanguage(query);
|
|
1313
|
+
const results = [];
|
|
1314
|
+
const sources = [];
|
|
1315
|
+
const attempts = [];
|
|
1316
|
+
const seenUrls = new Set();
|
|
1317
|
+
|
|
1318
|
+
const add = (item, defaultSource) => {
|
|
1319
|
+
if (!item?.url || seenUrls.has(item.url)) return;
|
|
1320
|
+
seenUrls.add(item.url);
|
|
1321
|
+
const src = item.source || defaultSource;
|
|
1322
|
+
results.push({ ...item, source: src });
|
|
1323
|
+
if (!sources.includes(src)) sources.push(src);
|
|
1324
|
+
};
|
|
1325
|
+
|
|
1326
|
+
const [gSettled, bSettled, ySettled, ddgSettled, imgSettled] = await Promise.allSettled([
|
|
1327
|
+
fetchGoogleNewsRss(query, capped, lang),
|
|
1328
|
+
fetchBingNewsRss(query, capped, lang),
|
|
1329
|
+
fetchYahooNewsRss(query, Math.ceil(capped / 2)),
|
|
1330
|
+
searchDuckDuckGo(`${query} news`, Math.min(capped, 4)),
|
|
1331
|
+
searchWebImages(`${query} news`, 6).catch(() => null)
|
|
1332
|
+
]);
|
|
1333
|
+
|
|
1334
|
+
if (gSettled.status === 'fulfilled' && Array.isArray(gSettled.value)) {
|
|
1335
|
+
attempts.push({ source: 'Google News', status: gSettled.value.length ? 'success' : 'no_results', count: gSettled.value.length });
|
|
1336
|
+
} else {
|
|
1337
|
+
attempts.push({ source: 'Google News', status: 'error', message: gSettled.reason?.message });
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
if (bSettled.status === 'fulfilled' && Array.isArray(bSettled.value)) {
|
|
1341
|
+
attempts.push({ source: 'Bing News', status: bSettled.value.length ? 'success' : 'no_results', count: bSettled.value.length });
|
|
1342
|
+
} else {
|
|
1343
|
+
attempts.push({ source: 'Bing News', status: 'error', message: bSettled.reason?.message });
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const gList = (gSettled.status === 'fulfilled' && Array.isArray(gSettled.value)) ? gSettled.value : [];
|
|
1347
|
+
const bList = (bSettled.status === 'fulfilled' && Array.isArray(bSettled.value)) ? bSettled.value : [];
|
|
1348
|
+
const yList = (ySettled.status === 'fulfilled' && Array.isArray(ySettled.value)) ? ySettled.value : [];
|
|
1349
|
+
const ddgList = (ddgSettled.status === 'fulfilled' && Array.isArray(ddgSettled.value)) ? ddgSettled.value : [];
|
|
1350
|
+
const imgRes = imgSettled.status === 'fulfilled' ? imgSettled.value : null;
|
|
1351
|
+
const newsImages = Array.isArray(imgRes?.results) ? imgRes.results.slice(0, 6) : [];
|
|
1352
|
+
|
|
1353
|
+
let i = 0;
|
|
1354
|
+
while (results.length < capped && (i < gList.length || i < bList.length || i < yList.length || i < ddgList.length)) {
|
|
1355
|
+
if (i < gList.length && results.length < capped) add(gList[i], 'Google News');
|
|
1356
|
+
if (i < bList.length && results.length < capped) add(bList[i], 'Bing News');
|
|
1357
|
+
if (i < yList.length && results.length < capped) add(yList[i], 'Yahoo News');
|
|
1358
|
+
if (i < ddgList.length && results.length < capped) add(ddgList[i], 'Web News');
|
|
1359
|
+
i++;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
return {
|
|
1363
|
+
status: results.length > 0 ? 'success' : 'no_results',
|
|
1364
|
+
query,
|
|
1365
|
+
language: lang,
|
|
1366
|
+
count: results.length,
|
|
1367
|
+
sources,
|
|
1368
|
+
results,
|
|
1369
|
+
...(newsImages.length > 0 ? { images: newsImages } : {})
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
export async function fetchRssFeed(url, maxItems = 10) {
|
|
1374
|
+
if (!url || typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
|
|
1375
|
+
return { status: 'error', message: 'Valid HTTP/HTTPS feed URL is required.' };
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
let parsedUrl;
|
|
1379
|
+
try {
|
|
1380
|
+
parsedUrl = new URL(url);
|
|
1381
|
+
} catch {
|
|
1382
|
+
return { status: 'error', message: 'Invalid URL format.' };
|
|
1383
|
+
}
|
|
1384
|
+
const ssrf = await assertSafeUrlHostname(parsedUrl.hostname);
|
|
1385
|
+
if (!ssrf.safe) {
|
|
1386
|
+
return { status: 'error', message: `URL rejected by SSRF guard: ${ssrf.reason}` };
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const capped = Math.min(Math.max(maxItems, 1), 30);
|
|
1390
|
+
|
|
1391
|
+
try {
|
|
1392
|
+
const res = await fetchWithTimeout(url, { headers: BROWSER_HEADERS }, 12000);
|
|
1393
|
+
if (!res.ok) {
|
|
1394
|
+
return { status: 'error', url, message: `HTTP ${res.status}: feed could not be loaded.` };
|
|
1395
|
+
}
|
|
1396
|
+
const xml = await readResponseTextLimited(res, 1_000_000);
|
|
1397
|
+
const items = parseRssFeed(xml).slice(0, capped);
|
|
1398
|
+
const feedTitle = cleanHtml(stripCdata((xml.match(/<(?:title)>[\s\S]*?<\/(?:title)>/i) || [''])[0]
|
|
1399
|
+
?.replace(/<\/?title[^>]*>/gi, '')) || '');
|
|
1400
|
+
|
|
1401
|
+
return {
|
|
1402
|
+
status: items.length > 0 ? 'success' : 'no_results',
|
|
1403
|
+
url: res.url || url,
|
|
1404
|
+
feedTitle,
|
|
1405
|
+
count: items.length,
|
|
1406
|
+
items
|
|
1407
|
+
};
|
|
1408
|
+
} catch (err) {
|
|
1409
|
+
return {
|
|
1410
|
+
status: 'error',
|
|
1411
|
+
url,
|
|
1412
|
+
message: err.name === 'AbortError' ? 'Feed fetch timed out (12s)' : err.message
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
export async function fetchWikipediaSummary(term, lang = 'en') {
|
|
1418
|
+
if (!term || typeof term !== 'string') {
|
|
1419
|
+
return { status: 'error', message: 'Search term is required.' };
|
|
1420
|
+
}
|
|
1421
|
+
const wikiLang = ['zh', 'th', 'en'].includes(lang) ? lang : 'en';
|
|
1422
|
+
const headers = { 'User-Agent': 'KikuAgent/2.0 (JuJingyi Fan Knowledge Bot)' };
|
|
1423
|
+
|
|
1424
|
+
const fetchExtract = async (title) => {
|
|
1425
|
+
const url = `https://${wikiLang}.wikipedia.org/w/api.php?action=query&format=json&utf8=1&redirects=1&prop=extracts|pageimages&exintro=1&explaintext=1&pithumbsize=320${wikiLang === 'zh' ? '&variant=zh-cn' : ''}&titles=${encodeURIComponent(title)}`;
|
|
1426
|
+
const res = await fetchWithTimeout(url, { headers }, 9000);
|
|
1427
|
+
if (!res.ok) throw new Error(`Wikipedia HTTP ${res.status}`);
|
|
1428
|
+
const data = await res.json();
|
|
1429
|
+
const pages = data?.query?.pages || {};
|
|
1430
|
+
return Object.values(pages).find(p => p && p.missing === undefined && p.extract) || null;
|
|
1431
|
+
};
|
|
1432
|
+
|
|
1433
|
+
const searchTitle = async () => {
|
|
1434
|
+
const url = `https://${wikiLang}.wikipedia.org/w/api.php?action=query&format=json&utf8=1&list=search&srlimit=1&srsearch=${encodeURIComponent(term)}`;
|
|
1435
|
+
const res = await fetchWithTimeout(url, { headers }, 9000);
|
|
1436
|
+
if (!res.ok) return null;
|
|
1437
|
+
const data = await res.json();
|
|
1438
|
+
return data?.query?.search?.[0]?.title || null;
|
|
1439
|
+
};
|
|
1440
|
+
|
|
1441
|
+
try {
|
|
1442
|
+
let page = await fetchExtract(term);
|
|
1443
|
+
if (!page) {
|
|
1444
|
+
const matchedTitle = await searchTitle();
|
|
1445
|
+
if (matchedTitle) page = await fetchExtract(matchedTitle);
|
|
1446
|
+
}
|
|
1447
|
+
if (!page) {
|
|
1448
|
+
return { status: 'not_found', term, source: `Wikipedia (${wikiLang})`, message: `No Wikipedia (${wikiLang}) article found for "${term}".` };
|
|
1449
|
+
}
|
|
1450
|
+
return {
|
|
1451
|
+
status: 'success',
|
|
1452
|
+
term,
|
|
1453
|
+
source: `Wikipedia (${wikiLang})`,
|
|
1454
|
+
title: page.title,
|
|
1455
|
+
summary: String(page.extract || '').slice(0, 3000),
|
|
1456
|
+
thumbnail: page.thumbnail?.source || null,
|
|
1457
|
+
url: `https://${wikiLang}.wikipedia.org/wiki/${encodeURIComponent(String(page.title).replace(/ /g, '_'))}`
|
|
1458
|
+
};
|
|
1459
|
+
} catch (err) {
|
|
1460
|
+
return {
|
|
1461
|
+
status: 'error',
|
|
1462
|
+
term,
|
|
1463
|
+
source: `Wikipedia (${wikiLang})`,
|
|
1464
|
+
message: err.name === 'AbortError' ? 'Wikipedia request timed out (9s)' : err.message
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function extractDivBlock(html, classHint) {
|
|
1470
|
+
const openMatch = html.match(new RegExp(`<div[^>]+class="[^"]*${classHint}[^"]*"[^>]*>`, 'i'));
|
|
1471
|
+
if (!openMatch) return '';
|
|
1472
|
+
const tagRegex = /<\/?div\b[^>]*>/gi;
|
|
1473
|
+
tagRegex.lastIndex = openMatch.index + openMatch[0].length;
|
|
1474
|
+
const start = tagRegex.lastIndex;
|
|
1475
|
+
let depth = 1;
|
|
1476
|
+
let tag;
|
|
1477
|
+
while ((tag = tagRegex.exec(html)) !== null) {
|
|
1478
|
+
depth += tag[0].startsWith('</') ? -1 : 1;
|
|
1479
|
+
if (depth === 0) return html.slice(start, tag.index);
|
|
1480
|
+
}
|
|
1481
|
+
return html.slice(start, start + 8000);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
export function parseBaikePage(html) {
|
|
1485
|
+
const text = String(html || '');
|
|
1486
|
+
const titleMatch = text.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)
|
|
1487
|
+
|| text.match(/<title>([\s\S]*?)<\/title>/i);
|
|
1488
|
+
const descriptionMatch = text.match(/<meta\s+name="description"\s+content="([^"]*)"/i);
|
|
1489
|
+
const summaryBlock = extractDivBlock(text, 'lemma-summary') || extractDivBlock(text, 'para_j');
|
|
1490
|
+
|
|
1491
|
+
const paragraphs = [];
|
|
1492
|
+
let paraMatch;
|
|
1493
|
+
const paraSource = summaryBlock || text;
|
|
1494
|
+
const pRegex = /<p[^>]*>([\s\S]*?)<\/p>/gi;
|
|
1495
|
+
while ((paraMatch = pRegex.exec(paraSource)) !== null && paragraphs.length < 6) {
|
|
1496
|
+
const para = cleanHtml(paraMatch[1]);
|
|
1497
|
+
if (para.length > 10) paragraphs.push(para);
|
|
1498
|
+
}
|
|
1499
|
+
if (paragraphs.length === 0) {
|
|
1500
|
+
const paraDivRegex = /<div[^>]+class="[^"]*para[^"]*"[^>]*>([\s\S]*?)<\/div>/gi;
|
|
1501
|
+
while ((paraMatch = paraDivRegex.exec(paraSource)) !== null && paragraphs.length < 6) {
|
|
1502
|
+
const para = cleanHtml(paraMatch[1]);
|
|
1503
|
+
if (para.length > 10) paragraphs.push(para);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
const title = cleanHtml(titleMatch ? titleMatch[1] : '').replace(/_百度百科$/, '').trim();
|
|
1508
|
+
const summary = cleanHtml(summaryBlock || (descriptionMatch ? descriptionMatch[1] : '')).slice(0, 3000);
|
|
1509
|
+
|
|
1510
|
+
return { title, summary, paragraphs };
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
export async function fetchBaikeSummary(term) {
|
|
1514
|
+
if (!term || typeof term !== 'string') {
|
|
1515
|
+
return { status: 'error', message: 'Search term is required.' };
|
|
1516
|
+
}
|
|
1517
|
+
const url = `https://baike.baidu.com/item/${encodeURIComponent(term)}`;
|
|
1518
|
+
|
|
1519
|
+
try {
|
|
1520
|
+
const res = await fetchWithTimeout(url, {
|
|
1521
|
+
headers: {
|
|
1522
|
+
...BROWSER_HEADERS,
|
|
1523
|
+
'Accept-Language': 'zh-CN,zh;q=0.9'
|
|
1524
|
+
}
|
|
1525
|
+
}, 12000);
|
|
1526
|
+
if (!res.ok) {
|
|
1527
|
+
return {
|
|
1528
|
+
status: 'error',
|
|
1529
|
+
term,
|
|
1530
|
+
source: 'Baidu Baike',
|
|
1531
|
+
message: `Baidu Baike returned HTTP ${res.status}. Fallback to Wikipedia (zh) or search_chinese_web.`
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
const html = await readResponseTextLimited(res, 1_000_000);
|
|
1535
|
+
if (html.includes('百度安全验证') || html.includes('wappass.baidu.com')) {
|
|
1536
|
+
return {
|
|
1537
|
+
status: 'error',
|
|
1538
|
+
term,
|
|
1539
|
+
source: 'Baidu Baike',
|
|
1540
|
+
blocked: true,
|
|
1541
|
+
message: 'Baidu Baike blocked the request (anti-bot verification). Use Wikipedia (zh) or search_chinese_web instead.'
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
const parsed = parseBaikePage(html);
|
|
1546
|
+
if (!parsed.title && !parsed.summary) {
|
|
1547
|
+
return {
|
|
1548
|
+
status: 'not_found',
|
|
1549
|
+
term,
|
|
1550
|
+
source: 'Baidu Baike',
|
|
1551
|
+
message: `Baidu Baike page could not be parsed for "${term}".`
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
return {
|
|
1555
|
+
status: 'success',
|
|
1556
|
+
term,
|
|
1557
|
+
source: 'Baidu Baike',
|
|
1558
|
+
title: parsed.title || term,
|
|
1559
|
+
summary: parsed.summary,
|
|
1560
|
+
paragraphs: parsed.paragraphs,
|
|
1561
|
+
url: res.url || url
|
|
1562
|
+
};
|
|
1563
|
+
} catch (err) {
|
|
1564
|
+
return {
|
|
1565
|
+
status: 'error',
|
|
1566
|
+
term,
|
|
1567
|
+
source: 'Baidu Baike',
|
|
1568
|
+
message: err.name === 'AbortError' ? 'Baidu Baike request timed out (12s)' : err.message
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
export async function fetchWikidataSummary(term, lang = 'zh') {
|
|
1574
|
+
try {
|
|
1575
|
+
const url = `https://www.wikidata.org/w/api.php?action=wbsearchentities&search=${encodeURIComponent(term)}&language=${lang}&limit=1&format=json`;
|
|
1576
|
+
const res = await fetchWithTimeout(url, { headers: { 'User-Agent': 'KikuAgent/2.0' } }, 6000);
|
|
1577
|
+
if (!res.ok) return { status: 'error', source: 'Wikidata', message: `HTTP ${res.status}` };
|
|
1578
|
+
const data = await res.json();
|
|
1579
|
+
const item = (data?.search || [])[0];
|
|
1580
|
+
if (!item) return { status: 'not_found', source: 'Wikidata' };
|
|
1581
|
+
return {
|
|
1582
|
+
status: 'success',
|
|
1583
|
+
term,
|
|
1584
|
+
source: 'Wikidata',
|
|
1585
|
+
title: item.label,
|
|
1586
|
+
summary: item.description || '',
|
|
1587
|
+
paragraphs: [item.description].filter(Boolean),
|
|
1588
|
+
url: `https://www.wikidata.org/wiki/${item.id}`
|
|
1589
|
+
};
|
|
1590
|
+
} catch (err) {
|
|
1591
|
+
return { status: 'error', source: 'Wikidata', message: err.message };
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
export async function fetchBangumiSummary(term) {
|
|
1596
|
+
try {
|
|
1597
|
+
const url = `https://api.bgm.tv/search/subject/${encodeURIComponent(term)}?responseGroup=small&max_results=1`;
|
|
1598
|
+
const res = await fetchWithTimeout(url, { headers: { 'User-Agent': 'KikuAgent/2.0' } }, 6000);
|
|
1599
|
+
if (!res.ok) return { status: 'error', source: 'Bangumi', message: `HTTP ${res.status}` };
|
|
1600
|
+
const data = await res.json();
|
|
1601
|
+
const item = (data?.list || [])[0];
|
|
1602
|
+
if (!item) return { status: 'not_found', source: 'Bangumi' };
|
|
1603
|
+
return {
|
|
1604
|
+
status: 'success',
|
|
1605
|
+
term,
|
|
1606
|
+
source: 'Bangumi/Moegirl',
|
|
1607
|
+
title: item.name_cn || item.name,
|
|
1608
|
+
summary: item.summary || '',
|
|
1609
|
+
paragraphs: [item.summary].filter(Boolean),
|
|
1610
|
+
thumbnail: item.images?.large || item.images?.common || null,
|
|
1611
|
+
url: item.url || `https://bgm.tv/subject/${item.id}`
|
|
1612
|
+
};
|
|
1613
|
+
} catch (err) {
|
|
1614
|
+
return { status: 'error', source: 'Bangumi/Moegirl', message: err.message };
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
export async function searchEncyclopedia(term, lang = 'en', maxEntries = 2) {
|
|
1619
|
+
if (!term || typeof term !== 'string') {
|
|
1620
|
+
return { status: 'error', message: 'Search term is required.' };
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
const capped = Math.min(Math.max(maxEntries, 1), 6);
|
|
1624
|
+
const entries = [];
|
|
1625
|
+
const attempts = [];
|
|
1626
|
+
|
|
1627
|
+
const collect = (result) => {
|
|
1628
|
+
if (result.status === 'success') {
|
|
1629
|
+
entries.push({
|
|
1630
|
+
source: result.source,
|
|
1631
|
+
title: result.title,
|
|
1632
|
+
summary: result.summary,
|
|
1633
|
+
paragraphs: result.paragraphs,
|
|
1634
|
+
thumbnail: result.thumbnail || null,
|
|
1635
|
+
url: result.url
|
|
1636
|
+
});
|
|
1637
|
+
return true;
|
|
1638
|
+
}
|
|
1639
|
+
attempts.push({ source: result.source, status: result.status, message: result.message });
|
|
1640
|
+
return false;
|
|
1641
|
+
};
|
|
1642
|
+
|
|
1643
|
+
const tasks = [];
|
|
1644
|
+
if (lang === 'zh') {
|
|
1645
|
+
tasks.push(fetchBaikeSummary(term));
|
|
1646
|
+
}
|
|
1647
|
+
tasks.push(fetchWikipediaSummary(term, lang));
|
|
1648
|
+
if (lang !== 'en') {
|
|
1649
|
+
tasks.push(fetchWikipediaSummary(term, 'en'));
|
|
1650
|
+
}
|
|
1651
|
+
tasks.push(fetchWikidataSummary(term, lang));
|
|
1652
|
+
tasks.push(fetchBangumiSummary(term));
|
|
1653
|
+
|
|
1654
|
+
const settled = await Promise.allSettled(tasks);
|
|
1655
|
+
for (const s of settled) {
|
|
1656
|
+
if (entries.length >= capped) break;
|
|
1657
|
+
if (s.status === 'fulfilled' && s.value) {
|
|
1658
|
+
collect(s.value);
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
if (entries.length === 0 && profileData) {
|
|
1663
|
+
const rawTerm = term.toLowerCase();
|
|
1664
|
+
const match = (profileData.nameEn && profileData.nameEn.toLowerCase().includes(rawTerm)) ||
|
|
1665
|
+
(profileData.nameZh && profileData.nameZh.includes(term)) ||
|
|
1666
|
+
(profileData.nameTh && profileData.nameTh.includes(term));
|
|
1667
|
+
if (match) {
|
|
1668
|
+
entries.push({
|
|
1669
|
+
source: 'Local Knowledge Base',
|
|
1670
|
+
title: `${profileData.nameZh || '鞠婧祎'} (${profileData.nameEn || 'Ju Jingyi'})`,
|
|
1671
|
+
summary: profileData.bio || profileData.shortBio || '鞠婧祎,中国内地流行乐女歌手、影视演员。',
|
|
1672
|
+
paragraphs: [profileData.bio, profileData.career].filter(Boolean),
|
|
1673
|
+
thumbnail: profileData.avatar || null,
|
|
1674
|
+
url: 'https://jujingyi-kikuuu.vercel.app'
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
return {
|
|
1680
|
+
status: entries.length > 0 ? 'success' : (attempts.some(a => a.status === 'error') ? 'error' : 'not_found'),
|
|
1681
|
+
term,
|
|
1682
|
+
language: lang,
|
|
1683
|
+
count: entries.length,
|
|
1684
|
+
entries,
|
|
1685
|
+
attempts
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
export async function searchItunesMusic(term, limit = 8) {
|
|
1690
|
+
const capped = Math.min(Math.max(limit, 1), 25);
|
|
1691
|
+
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(term)}&media=music&entity=musicTrack,musicVideo,album&limit=${capped}`;
|
|
1692
|
+
try {
|
|
1693
|
+
const res = await fetchWithTimeout(url, { headers: { 'Accept': 'application/json' } }, 9000);
|
|
1694
|
+
if (!res.ok) throw new Error(`iTunes HTTP ${res.status}`);
|
|
1695
|
+
const data = await res.json();
|
|
1696
|
+
const items = (data?.results || []).map(r => ({
|
|
1697
|
+
kind: r.kind || 'track',
|
|
1698
|
+
title: r.trackName || r.collectionName || '',
|
|
1699
|
+
artist: r.artistName || '',
|
|
1700
|
+
album: r.collectionName || '',
|
|
1701
|
+
releasedAt: r.releaseDate || '',
|
|
1702
|
+
previewUrl: r.previewUrl || null,
|
|
1703
|
+
url: r.trackViewUrl || r.collectionViewUrl || '',
|
|
1704
|
+
artworkUrl: r.artworkUrl100 || null
|
|
1705
|
+
})).filter(r => r.title);
|
|
1706
|
+
return { status: items.length > 0 ? 'success' : 'no_results', source: 'iTunes', count: items.length, results: items };
|
|
1707
|
+
} catch (err) {
|
|
1708
|
+
return {
|
|
1709
|
+
status: 'error',
|
|
1710
|
+
source: 'iTunes',
|
|
1711
|
+
message: err.name === 'AbortError' ? 'iTunes request timed out (9s)' : err.message
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
export async function searchMusicBrainz(term, limit = 8) {
|
|
1717
|
+
const capped = Math.min(Math.max(limit, 1), 25);
|
|
1718
|
+
const buildUrl = (query) =>
|
|
1719
|
+
`https://musicbrainz.org/ws/2/recording?query=${encodeURIComponent(query)}&fmt=json&limit=${capped}`;
|
|
1720
|
+
const headers = {
|
|
1721
|
+
'User-Agent': 'KikuAgent/2.0 (Ju Jingyi fan knowledge agent)',
|
|
1722
|
+
'Accept': 'application/json'
|
|
1723
|
+
};
|
|
1724
|
+
|
|
1725
|
+
const fetchQuery = async (query) => {
|
|
1726
|
+
const res = await fetchWithTimeout(buildUrl(query), { headers }, 10000);
|
|
1727
|
+
if (!res.ok) throw new Error(`MusicBrainz HTTP ${res.status}`);
|
|
1728
|
+
return res.json();
|
|
1729
|
+
};
|
|
1730
|
+
|
|
1731
|
+
try {
|
|
1732
|
+
let data = await fetchQuery(`artist:"Ju Jingyi" AND ${term}`);
|
|
1733
|
+
let recordings = data?.recordings || [];
|
|
1734
|
+
if (recordings.length === 0) {
|
|
1735
|
+
data = await fetchQuery(term);
|
|
1736
|
+
recordings = data?.recordings || [];
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
const items = recordings.map(r => ({
|
|
1740
|
+
title: r.title || '',
|
|
1741
|
+
artist: (r['artist-credit'] || []).map(a => a?.name).filter(Boolean).join(', '),
|
|
1742
|
+
score: r.score || 0,
|
|
1743
|
+
lengthMs: r.length || null,
|
|
1744
|
+
releases: (r.releases || []).slice(0, 3).map(x => ({
|
|
1745
|
+
title: x.title || '',
|
|
1746
|
+
date: x.date || '',
|
|
1747
|
+
country: x.country || ''
|
|
1748
|
+
}))
|
|
1749
|
+
})).filter(r => r.title);
|
|
1750
|
+
|
|
1751
|
+
return { status: items.length > 0 ? 'success' : 'no_results', source: 'MusicBrainz', count: items.length, results: items };
|
|
1752
|
+
} catch (err) {
|
|
1753
|
+
return {
|
|
1754
|
+
status: 'error',
|
|
1755
|
+
source: 'MusicBrainz',
|
|
1756
|
+
message: err.name === 'AbortError' ? 'MusicBrainz request timed out (10s)' : err.message
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
export async function searchMusicPlatforms(term, maxResults = 8) {
|
|
1762
|
+
if (!term || typeof term !== 'string') {
|
|
1763
|
+
return { status: 'error', message: 'Search term is required for music platform search.' };
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
const capped = Math.min(Math.max(maxResults, 1), 25);
|
|
1767
|
+
const results = [];
|
|
1768
|
+
const sources = [];
|
|
1769
|
+
const seenTracks = new Set();
|
|
1770
|
+
|
|
1771
|
+
const addTrack = (item, sourceName) => {
|
|
1772
|
+
const norm = (item.title + '|' + (item.artist || '')).toLowerCase().trim();
|
|
1773
|
+
if (seenTracks.has(norm)) return;
|
|
1774
|
+
seenTracks.add(norm);
|
|
1775
|
+
results.push({ ...item, source: item.source || sourceName });
|
|
1776
|
+
if (!sources.includes(item.source || sourceName)) sources.push(item.source || sourceName);
|
|
1777
|
+
};
|
|
1778
|
+
|
|
1779
|
+
|
|
1780
|
+
if (Array.isArray(discographyData)) {
|
|
1781
|
+
const termLower = term.toLowerCase();
|
|
1782
|
+
const localTracks = discographyData.filter(d =>
|
|
1783
|
+
(d.title && d.title.toLowerCase().includes(termLower)) ||
|
|
1784
|
+
(d.titleCn && d.titleCn.includes(term)) ||
|
|
1785
|
+
(d.titleTh && d.titleTh.includes(term)) ||
|
|
1786
|
+
(d.album && d.album.toLowerCase().includes(termLower))
|
|
1787
|
+
);
|
|
1788
|
+
for (const t of localTracks.slice(0, 3)) {
|
|
1789
|
+
addTrack({
|
|
1790
|
+
kind: 'track',
|
|
1791
|
+
title: t.titleCn || t.title,
|
|
1792
|
+
artist: '鞠婧祎 (Ju Jingyi)',
|
|
1793
|
+
album: t.album || 'Single',
|
|
1794
|
+
releasedAt: t.releaseDate || t.year || '',
|
|
1795
|
+
url: t.audioUrl || t.videoUrl || '',
|
|
1796
|
+
source: 'Verified Official Discography'
|
|
1797
|
+
}, 'Verified Official Discography');
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
const fetchBilibiliMusic = async () => {
|
|
1802
|
+
try {
|
|
1803
|
+
const url = `https://api.bilibili.com/x/web-interface/search/type?search_type=video&keyword=${encodeURIComponent(term + ' 音乐 OST')}`;
|
|
1804
|
+
const res = await fetchWithTimeout(url, { headers: BROWSER_HEADERS }, 8000);
|
|
1805
|
+
if (!res.ok) return [];
|
|
1806
|
+
const json = await res.json().catch(() => null);
|
|
1807
|
+
const vlist = (json?.data?.result || []).slice(0, 4);
|
|
1808
|
+
return vlist.map(v => ({
|
|
1809
|
+
kind: 'musicVideo',
|
|
1810
|
+
title: cleanHtml(v.title || ''),
|
|
1811
|
+
artist: v.author || 'Bilibili Creator',
|
|
1812
|
+
album: 'Bilibili Music/Stage',
|
|
1813
|
+
url: `https://www.bilibili.com/video/${v.bvid}`,
|
|
1814
|
+
source: 'Bilibili Music & OST'
|
|
1815
|
+
}));
|
|
1816
|
+
} catch {
|
|
1817
|
+
return [];
|
|
1818
|
+
}
|
|
1819
|
+
};
|
|
1820
|
+
|
|
1821
|
+
const [itunes, musicBrainz, biliMusic] = await Promise.allSettled([
|
|
1822
|
+
searchItunesMusic(term, capped),
|
|
1823
|
+
searchMusicBrainz(term, capped),
|
|
1824
|
+
fetchBilibiliMusic()
|
|
1825
|
+
]);
|
|
1826
|
+
|
|
1827
|
+
if (itunes.status === 'fulfilled' && itunes.value?.results?.length) {
|
|
1828
|
+
itunes.value.results.forEach(r => addTrack(r, 'iTunes'));
|
|
1829
|
+
}
|
|
1830
|
+
if (musicBrainz.status === 'fulfilled' && musicBrainz.value?.results?.length) {
|
|
1831
|
+
musicBrainz.value.results.forEach(r => addTrack(r, 'MusicBrainz'));
|
|
1832
|
+
}
|
|
1833
|
+
if (biliMusic.status === 'fulfilled' && Array.isArray(biliMusic.value)) {
|
|
1834
|
+
biliMusic.value.forEach(r => addTrack(r, 'Bilibili Music & OST'));
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
if (results.length < capped) {
|
|
1838
|
+
addTrack({
|
|
1839
|
+
kind: 'searchBridge',
|
|
1840
|
+
title: `QQ 音乐: "${term}"`,
|
|
1841
|
+
artist: 'QQ Music Web Bridge',
|
|
1842
|
+
album: 'Online Streaming',
|
|
1843
|
+
url: `https://y.qq.com/n/ryqq/search?w=${encodeURIComponent(term)}`,
|
|
1844
|
+
source: 'QQ Music Bridge'
|
|
1845
|
+
}, 'QQ Music Bridge');
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
return {
|
|
1849
|
+
status: results.length > 0 ? 'success' : 'no_results',
|
|
1850
|
+
term,
|
|
1851
|
+
count: results.length,
|
|
1852
|
+
sources: [...new Set(sources)],
|
|
1853
|
+
results: results.slice(0, capped)
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
export function parseBaiduResults(html, maxResults = 8) {
|
|
1858
|
+
const results = [];
|
|
1859
|
+
const seenUrls = new Set();
|
|
1860
|
+
const regex = /<h3[^>]*>\s*<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/h3>([\s\S]*?)(?=<h3|<div[^>]+id="page"|<\/div>\s*<div[^>]+id="rs"|<\/body>|$)/gi;
|
|
1861
|
+
let match;
|
|
1862
|
+
while ((match = regex.exec(html)) !== null && results.length < maxResults) {
|
|
1863
|
+
const title = cleanHtml(match[2]);
|
|
1864
|
+
let rawSnippet = cleanHtml(match[3]);
|
|
1865
|
+
rawSnippet = rawSnippet.replace(/["']?\}[\],\}\s]+.*$/g, '').trim();
|
|
1866
|
+
const snippet = rawSnippet.slice(0, 320);
|
|
1867
|
+
if (title && match[1] && !seenUrls.has(match[1])) {
|
|
1868
|
+
seenUrls.add(match[1]);
|
|
1869
|
+
results.push({ title, url: match[1], snippet, source: 'Baidu' });
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
return results;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
export async function searchChineseWeb(query, maxResults = 8, options = {}) {
|
|
1876
|
+
if (!query || typeof query !== 'string') {
|
|
1877
|
+
return { status: 'error', message: 'Query string is required for Chinese web search.' };
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
const effectiveQuery = typeof options.queryEnricher === 'function'
|
|
1881
|
+
? options.queryEnricher(query, 'chinese_web')
|
|
1882
|
+
: getContextualizedSearchQuery(query, 'chinese_web');
|
|
1883
|
+
|
|
1884
|
+
const capped = Math.min(Math.max(maxResults, 1), 20);
|
|
1885
|
+
const results = [];
|
|
1886
|
+
const sources = [];
|
|
1887
|
+
const attempts = [];
|
|
1888
|
+
|
|
1889
|
+
const fetchBaidu = async () => {
|
|
1890
|
+
const t0 = Date.now();
|
|
1891
|
+
try {
|
|
1892
|
+
const baiduUrl = `https://www.baidu.com/s?wd=${encodeURIComponent(effectiveQuery)}&rn=${Math.min(capped * 2, 20)}`;
|
|
1893
|
+
const res = await fetchWithTimeout(baiduUrl, {
|
|
1894
|
+
headers: {
|
|
1895
|
+
...BROWSER_HEADERS,
|
|
1896
|
+
'Accept-Language': 'zh-CN,zh;q=0.9',
|
|
1897
|
+
'Referer': 'https://www.baidu.com/'
|
|
1898
|
+
}
|
|
1899
|
+
}, 10000);
|
|
1900
|
+
if (res.ok) {
|
|
1901
|
+
const html = await readResponseTextLimited(res, 3_000_000);
|
|
1902
|
+
if (html.includes('百度安全验证') || html.includes('wappass.baidu.com')) {
|
|
1903
|
+
attempts.push({ source: 'Baidu', status: 'blocked', message: 'Baidu anti-bot verification challenge.', durationMs: Date.now() - t0 });
|
|
1904
|
+
return [];
|
|
1905
|
+
}
|
|
1906
|
+
const items = parseBaiduResults(html, capped);
|
|
1907
|
+
attempts.push({ source: 'Baidu', status: items.length ? 'success' : 'no_results', count: items.length, durationMs: Date.now() - t0 });
|
|
1908
|
+
return items;
|
|
1909
|
+
}
|
|
1910
|
+
attempts.push({ source: 'Baidu', status: 'error', message: `HTTP ${res.status}`, durationMs: Date.now() - t0 });
|
|
1911
|
+
} catch (err) {
|
|
1912
|
+
attempts.push({ source: 'Baidu', status: 'error', message: err.name === 'AbortError' ? 'Baidu timeout (10s)' : err.message, durationMs: Date.now() - t0 });
|
|
1913
|
+
}
|
|
1914
|
+
return [];
|
|
1915
|
+
};
|
|
1916
|
+
|
|
1917
|
+
const fetchBingZh = async () => {
|
|
1918
|
+
const t0 = Date.now();
|
|
1919
|
+
try {
|
|
1920
|
+
const items = await searchBing(effectiveQuery, capped, { language: 'zh' });
|
|
1921
|
+
attempts.push({ source: 'Bing (zh)', status: items.length ? 'success' : 'no_results', count: items.length, durationMs: Date.now() - t0 });
|
|
1922
|
+
return items.map(it => ({ ...it, source: 'Bing (zh)' }));
|
|
1923
|
+
} catch (err) {
|
|
1924
|
+
attempts.push({ source: 'Bing (zh)', status: 'error', message: err.message, durationMs: Date.now() - t0 });
|
|
1925
|
+
return [];
|
|
1926
|
+
}
|
|
1927
|
+
};
|
|
1928
|
+
|
|
1929
|
+
const fetchNewsZh = async () => {
|
|
1930
|
+
const t0 = Date.now();
|
|
1931
|
+
try {
|
|
1932
|
+
const [gNews, bNews] = await Promise.allSettled([
|
|
1933
|
+
fetchGoogleNewsRss(effectiveQuery, Math.ceil(capped / 2), 'zh'),
|
|
1934
|
+
fetchBingNewsRss(effectiveQuery, Math.ceil(capped / 2), 'zh')
|
|
1935
|
+
]);
|
|
1936
|
+
const items = [];
|
|
1937
|
+
if (gNews.status === 'fulfilled' && Array.isArray(gNews.value)) items.push(...gNews.value);
|
|
1938
|
+
if (bNews.status === 'fulfilled' && Array.isArray(bNews.value)) items.push(...bNews.value);
|
|
1939
|
+
attempts.push({ source: 'Chinese News Media', status: items.length ? 'success' : 'no_results', count: items.length, durationMs: Date.now() - t0 });
|
|
1940
|
+
return items.map(it => ({
|
|
1941
|
+
title: it.title,
|
|
1942
|
+
url: it.url,
|
|
1943
|
+
snippet: it.snippet,
|
|
1944
|
+
source: it.source ? `Chinese News (${it.source})` : 'Chinese News Media'
|
|
1945
|
+
}));
|
|
1946
|
+
} catch (err) {
|
|
1947
|
+
attempts.push({ source: 'Chinese News Media', status: 'error', message: err.message, durationMs: Date.now() - t0 });
|
|
1948
|
+
return [];
|
|
1949
|
+
}
|
|
1950
|
+
};
|
|
1951
|
+
|
|
1952
|
+
const fetchBilibili = async () => {
|
|
1953
|
+
const t0 = Date.now();
|
|
1954
|
+
try {
|
|
1955
|
+
const url = `https://api.bilibili.com/x/web-interface/search/all/v2?keyword=${encodeURIComponent(effectiveQuery)}`;
|
|
1956
|
+
const res = await fetchWithTimeout(url, {
|
|
1957
|
+
headers: {
|
|
1958
|
+
...BROWSER_HEADERS,
|
|
1959
|
+
'Accept-Language': 'zh-CN,zh;q=0.9',
|
|
1960
|
+
'Referer': 'https://www.bilibili.com/'
|
|
1961
|
+
}
|
|
1962
|
+
}, 8000);
|
|
1963
|
+
if (res.ok) {
|
|
1964
|
+
const json = await res.json().catch(() => null);
|
|
1965
|
+
const vlist = (json?.data?.result || []).find(r => r.result_type === 'video')?.data || [];
|
|
1966
|
+
const items = vlist.slice(0, 4).map(v => ({
|
|
1967
|
+
title: cleanHtml(v.title || ''),
|
|
1968
|
+
url: `https://www.bilibili.com/video/${v.bvid}`,
|
|
1969
|
+
snippet: cleanHtml(v.description || `Bilibili video: ${v.author || ''}`).slice(0, 300),
|
|
1970
|
+
source: 'Bilibili Web'
|
|
1971
|
+
}));
|
|
1972
|
+
attempts.push({ source: 'Bilibili', status: items.length ? 'success' : 'no_results', count: items.length, durationMs: Date.now() - t0 });
|
|
1973
|
+
return items;
|
|
1974
|
+
}
|
|
1975
|
+
} catch (err) {
|
|
1976
|
+
attempts.push({ source: 'Bilibili', status: 'error', message: err.message, durationMs: Date.now() - t0 });
|
|
1977
|
+
}
|
|
1978
|
+
return [];
|
|
1979
|
+
};
|
|
1980
|
+
|
|
1981
|
+
const fetchWikiZh = async () => {
|
|
1982
|
+
const t0 = Date.now();
|
|
1983
|
+
try {
|
|
1984
|
+
const items = await searchWikipedia(effectiveQuery, 2, 'zh');
|
|
1985
|
+
attempts.push({ source: 'Chinese Wikipedia', status: items.length ? 'success' : 'no_results', count: items.length, durationMs: Date.now() - t0 });
|
|
1986
|
+
return items.map(it => ({ ...it, source: 'Chinese Wikipedia' }));
|
|
1987
|
+
} catch (err) {
|
|
1988
|
+
attempts.push({ source: 'Chinese Wikipedia', status: 'error', message: err.message, durationMs: Date.now() - t0 });
|
|
1989
|
+
return [];
|
|
1990
|
+
}
|
|
1991
|
+
};
|
|
1992
|
+
|
|
1993
|
+
const fetchBaike = async () => {
|
|
1994
|
+
const t0 = Date.now();
|
|
1995
|
+
try {
|
|
1996
|
+
const baike = await fetchBaikeSummary(effectiveQuery);
|
|
1997
|
+
if (baike.status === 'success') {
|
|
1998
|
+
attempts.push({ source: 'Baidu Baike', status: 'success', count: 1, durationMs: Date.now() - t0 });
|
|
1999
|
+
return [{
|
|
2000
|
+
title: baike.title ? `${baike.title} - 百度百科` : '百度百科',
|
|
2001
|
+
url: baike.url,
|
|
2002
|
+
snippet: (baike.summary || '').slice(0, 320),
|
|
2003
|
+
source: 'Baidu Baike'
|
|
2004
|
+
}];
|
|
2005
|
+
}
|
|
2006
|
+
} catch (err) {
|
|
2007
|
+
attempts.push({ source: 'Baidu Baike', status: 'error', message: err.message, durationMs: Date.now() - t0 });
|
|
2008
|
+
}
|
|
2009
|
+
return [];
|
|
2010
|
+
};
|
|
2011
|
+
|
|
2012
|
+
const fetchOpenWeb = async () => {
|
|
2013
|
+
const t0 = Date.now();
|
|
2014
|
+
try {
|
|
2015
|
+
const cnDomains = options.site ? [options.site] : ['sina.com.cn', 'sohu.com', 'weibo.com', '163.com', 'qq.com'];
|
|
2016
|
+
const ddgPromises = cnDomains.slice(0, 3).map(domain =>
|
|
2017
|
+
searchDuckDuckGo(`${effectiveQuery} site:${domain}`, 2)
|
|
2018
|
+
.then(items => items.map(it => ({ ...it, source: `Web (${domain})` })))
|
|
2019
|
+
.catch(() => [])
|
|
2020
|
+
);
|
|
2021
|
+
const ddgResults = (await Promise.all(ddgPromises)).flat();
|
|
2022
|
+
attempts.push({ source: 'Open Web Portals', status: ddgResults.length ? 'success' : 'no_results', count: ddgResults.length, durationMs: Date.now() - t0 });
|
|
2023
|
+
return ddgResults;
|
|
2024
|
+
} catch (err) {
|
|
2025
|
+
attempts.push({ source: 'Open Web Portals', status: 'error', message: err.message, durationMs: Date.now() - t0 });
|
|
2026
|
+
return [];
|
|
2027
|
+
}
|
|
2028
|
+
};
|
|
2029
|
+
|
|
2030
|
+
|
|
2031
|
+
const settled = await Promise.allSettled([
|
|
2032
|
+
fetchBaidu(),
|
|
2033
|
+
fetchBingZh(),
|
|
2034
|
+
fetchNewsZh(),
|
|
2035
|
+
fetchBilibili(),
|
|
2036
|
+
fetchWikiZh(),
|
|
2037
|
+
fetchBaike(),
|
|
2038
|
+
fetchOpenWeb()
|
|
2039
|
+
]);
|
|
2040
|
+
|
|
2041
|
+
const buckets = settled.map(s => (s.status === 'fulfilled' && Array.isArray(s.value)) ? s.value : []);
|
|
2042
|
+
|
|
2043
|
+
const seenUrls = new Set();
|
|
2044
|
+
const seenTitles = new Set();
|
|
2045
|
+
let added = true;
|
|
2046
|
+
let baikeCount = 0;
|
|
2047
|
+
|
|
2048
|
+
|
|
2049
|
+
while (results.length < capped && added) {
|
|
2050
|
+
added = false;
|
|
2051
|
+
for (const bucket of buckets) {
|
|
2052
|
+
if (results.length >= capped) break;
|
|
2053
|
+
if (bucket.length > 0) {
|
|
2054
|
+
const item = bucket.shift();
|
|
2055
|
+
const normUrl = item.url.replace(/^https?:\/\//, '').replace(/\/$/, '').toLowerCase();
|
|
2056
|
+
const normTitle = item.title.trim().toLowerCase();
|
|
2057
|
+
|
|
2058
|
+
|
|
2059
|
+
if (item.source.includes('Baike') && baikeCount >= 1) continue;
|
|
2060
|
+
if (seenUrls.has(normUrl) || seenTitles.has(normTitle)) continue;
|
|
2061
|
+
|
|
2062
|
+
seenUrls.add(normUrl);
|
|
2063
|
+
seenTitles.add(normTitle);
|
|
2064
|
+
if (item.source.includes('Baike')) baikeCount++;
|
|
2065
|
+
|
|
2066
|
+
results.push(item);
|
|
2067
|
+
if (!sources.includes(item.source)) sources.push(item.source);
|
|
2068
|
+
added = true;
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
let cnImages = [];
|
|
2074
|
+
try {
|
|
2075
|
+
const imgRes = await searchWebImages(query, 6).catch(() => null);
|
|
2076
|
+
if (imgRes && Array.isArray(imgRes.results)) {
|
|
2077
|
+
cnImages = imgRes.results.slice(0, 6);
|
|
2078
|
+
}
|
|
2079
|
+
} catch {}
|
|
2080
|
+
|
|
2081
|
+
return {
|
|
2082
|
+
status: results.length > 0 ? 'success' : 'no_results',
|
|
2083
|
+
query,
|
|
2084
|
+
count: results.length,
|
|
2085
|
+
sources: [...new Set(sources)],
|
|
2086
|
+
attempts,
|
|
2087
|
+
results,
|
|
2088
|
+
...(cnImages.length > 0 ? { images: cnImages } : {})
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
export function decodeBaiduUrl(url) {
|
|
2093
|
+
if (!url || typeof url !== 'string') return '';
|
|
2094
|
+
const trimmed = url.trim();
|
|
2095
|
+
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) return trimmed;
|
|
2096
|
+
if (!trimmed.includes('ippr_z2C$q') && !trimmed.includes('AzdH3F')) return trimmed;
|
|
2097
|
+
|
|
2098
|
+
const keywordMap = {
|
|
2099
|
+
'_z2C$q': ':',
|
|
2100
|
+
'_z&e3B': '.',
|
|
2101
|
+
'AzdH3F': '/'
|
|
2102
|
+
};
|
|
2103
|
+
|
|
2104
|
+
const charMap = {
|
|
2105
|
+
'w': 'a', 'k': 'b', 'v': 'c', '1': 'd', 'j': 'e', 'u': 'f', '2': 'g', 'i': 'h', 't': 'i', '3': 'j', 'h': 'k',
|
|
2106
|
+
's': 'l', '4': 'm', 'g': 'n', '5': 'o', 'r': 'p', 'q': 'q', '6': 'r', 'f': 's', 'p': 't', '7': 'u', 'e': 'v',
|
|
2107
|
+
'o': 'w', '8': '1', 'd': '2', 'n': '3', '9': '4', 'c': '5', 'm': '6', '0': '7', 'b': '8', 'l': '9', 'a': '0'
|
|
2108
|
+
};
|
|
2109
|
+
|
|
2110
|
+
let step1 = trimmed;
|
|
2111
|
+
for (const [k, v] of Object.entries(keywordMap)) {
|
|
2112
|
+
step1 = step1.replaceAll(k, v);
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
let res = '';
|
|
2116
|
+
for (const ch of step1) {
|
|
2117
|
+
res += charMap[ch] !== undefined ? charMap[ch] : ch;
|
|
2118
|
+
}
|
|
2119
|
+
return res;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
export async function searchWebImages(query, maxResults = 8) {
|
|
2123
|
+
if (!query || typeof query !== 'string') {
|
|
2124
|
+
return { status: 'error', message: 'Query string is required for image search.' };
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
const effectiveQuery = getContextualizedSearchQuery(query, 'chinese_web');
|
|
2128
|
+
const capped = Math.min(Math.max(maxResults, 1), 25);
|
|
2129
|
+
const results = [];
|
|
2130
|
+
const sources = [];
|
|
2131
|
+
const seenUrls = new Set();
|
|
2132
|
+
|
|
2133
|
+
const addImage = (item, defaultSource) => {
|
|
2134
|
+
if (!item?.imageUrl || seenUrls.has(item.imageUrl)) return false;
|
|
2135
|
+
seenUrls.add(item.imageUrl);
|
|
2136
|
+
const src = item.source || defaultSource;
|
|
2137
|
+
let sourcePageUrl = item.sourcePageUrl || '';
|
|
2138
|
+
if (sourcePageUrl && (sourcePageUrl.includes('ippr_z2C$q') || sourcePageUrl.includes('AzdH3F'))) {
|
|
2139
|
+
sourcePageUrl = decodeBaiduUrl(sourcePageUrl);
|
|
2140
|
+
}
|
|
2141
|
+
if (sourcePageUrl && !/^https?:\/\//i.test(sourcePageUrl)) {
|
|
2142
|
+
sourcePageUrl = '';
|
|
2143
|
+
}
|
|
2144
|
+
results.push({ ...item, sourcePageUrl, source: src });
|
|
2145
|
+
if (!sources.includes(src)) sources.push(src);
|
|
2146
|
+
return true;
|
|
2147
|
+
};
|
|
2148
|
+
|
|
2149
|
+
|
|
2150
|
+
if (Array.isArray(galleryData)) {
|
|
2151
|
+
const qLower = query.toLowerCase();
|
|
2152
|
+
const localMatches = galleryData.filter(g =>
|
|
2153
|
+
(g.title && g.title.toLowerCase().includes(qLower)) ||
|
|
2154
|
+
(g.category && g.category.toLowerCase().includes(qLower)) ||
|
|
2155
|
+
(g.tags && g.tags.some(t => t.toLowerCase().includes(qLower)))
|
|
2156
|
+
);
|
|
2157
|
+
for (const g of localMatches.slice(0, 3)) {
|
|
2158
|
+
addImage({
|
|
2159
|
+
title: g.title || effectiveQuery,
|
|
2160
|
+
imageUrl: g.url,
|
|
2161
|
+
thumbUrl: g.url,
|
|
2162
|
+
sourcePageUrl: 'https://jujingyi-kikuuu.vercel.app/#gallery',
|
|
2163
|
+
width: 1080,
|
|
2164
|
+
height: 1440,
|
|
2165
|
+
source: 'Official Gallery'
|
|
2166
|
+
}, 'Official Gallery');
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
|
|
2171
|
+
const fetchBaiduImages = async () => {
|
|
2172
|
+
try {
|
|
2173
|
+
const baiduImgUrl = `https://image.baidu.com/search/acjson?tn=resultjson_com&logid=${Date.now()}&ipn=rj&ct=201326592&is=&fp=result&word=${encodeURIComponent(effectiveQuery)}&queryWord=${encodeURIComponent(effectiveQuery)}&cl=2&lm=-1&ie=utf-8&oe=utf-8&pn=0&rn=${Math.min(capped * 2, 30)}&gsm=1e`;
|
|
2174
|
+
const res = await fetchWithTimeout(baiduImgUrl, {
|
|
2175
|
+
headers: {
|
|
2176
|
+
...BROWSER_HEADERS,
|
|
2177
|
+
'Referer': 'https://image.baidu.com/',
|
|
2178
|
+
'Accept': 'text/plain, */*; q=0.01',
|
|
2179
|
+
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
2180
|
+
}
|
|
2181
|
+
}, 10000);
|
|
2182
|
+
if (res.ok) {
|
|
2183
|
+
const json = await res.json().catch(() => null);
|
|
2184
|
+
const items = Array.isArray(json?.data) ? json.data : [];
|
|
2185
|
+
return items.map(item => {
|
|
2186
|
+
let rawImgUrl = item.middleURL || item.thumbURL || item.hoverURL;
|
|
2187
|
+
if (!rawImgUrl || !rawImgUrl.startsWith('http')) {
|
|
2188
|
+
if (item.objURL) {
|
|
2189
|
+
const decodedObj = decodeBaiduUrl(item.objURL);
|
|
2190
|
+
if (decodedObj && decodedObj.startsWith('http')) {
|
|
2191
|
+
rawImgUrl = decodedObj;
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
if (!rawImgUrl || typeof rawImgUrl !== 'string' || !rawImgUrl.startsWith('http')) return null;
|
|
2196
|
+
|
|
2197
|
+
let sourcePageUrl = decodeBaiduUrl(item.fromURL || '');
|
|
2198
|
+
if (!sourcePageUrl || !/^https?:\/\//i.test(sourcePageUrl)) {
|
|
2199
|
+
if (item.fromURLHost && typeof item.fromURLHost === 'string' && item.fromURLHost.includes('.')) {
|
|
2200
|
+
sourcePageUrl = `https://${item.fromURLHost}`;
|
|
2201
|
+
} else {
|
|
2202
|
+
sourcePageUrl = '';
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
let thumbUrl = item.thumbURL || rawImgUrl;
|
|
2207
|
+
if (thumbUrl && (thumbUrl.includes('ippr_z2C$q') || thumbUrl.includes('AzdH3F'))) {
|
|
2208
|
+
thumbUrl = decodeBaiduUrl(thumbUrl);
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
return {
|
|
2212
|
+
title: cleanHtml(item.fromPageTitleEnc || item.fromPageTitle || effectiveQuery),
|
|
2213
|
+
imageUrl: rawImgUrl,
|
|
2214
|
+
thumbUrl: thumbUrl || rawImgUrl,
|
|
2215
|
+
sourcePageUrl: sourcePageUrl,
|
|
2216
|
+
width: Number(item.width) || null,
|
|
2217
|
+
height: Number(item.height) || null,
|
|
2218
|
+
source: 'Baidu Images'
|
|
2219
|
+
};
|
|
2220
|
+
}).filter(Boolean);
|
|
2221
|
+
}
|
|
2222
|
+
} catch {}
|
|
2223
|
+
return [];
|
|
2224
|
+
};
|
|
2225
|
+
|
|
2226
|
+
|
|
2227
|
+
const fetchBingImages = async () => {
|
|
2228
|
+
try {
|
|
2229
|
+
const url = `https://www.bing.com/images/async?q=${encodeURIComponent(effectiveQuery)}&mmasync=1`;
|
|
2230
|
+
const res = await fetchWithTimeout(url, {
|
|
2231
|
+
headers: {
|
|
2232
|
+
...BROWSER_HEADERS,
|
|
2233
|
+
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8'
|
|
2234
|
+
}
|
|
2235
|
+
}, 10000);
|
|
2236
|
+
if (res.ok) {
|
|
2237
|
+
const html = await res.text();
|
|
2238
|
+
const items = [];
|
|
2239
|
+
const regex = /m="({[\s\S]*?})"/g;
|
|
2240
|
+
let match;
|
|
2241
|
+
while ((match = regex.exec(html)) !== null && items.length < capped) {
|
|
2242
|
+
try {
|
|
2243
|
+
const data = JSON.parse(match[1].replace(/"/g, '"'));
|
|
2244
|
+
if (data.murl && data.murl.startsWith('http')) {
|
|
2245
|
+
items.push({
|
|
2246
|
+
title: cleanHtml(data.t || effectiveQuery),
|
|
2247
|
+
imageUrl: data.murl,
|
|
2248
|
+
thumbUrl: data.turl || data.murl,
|
|
2249
|
+
sourcePageUrl: data.purl || '',
|
|
2250
|
+
width: Number(data.w) || null,
|
|
2251
|
+
height: Number(data.h) || null,
|
|
2252
|
+
source: 'Bing Images'
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2255
|
+
} catch {}
|
|
2256
|
+
}
|
|
2257
|
+
return items;
|
|
2258
|
+
}
|
|
2259
|
+
} catch {}
|
|
2260
|
+
return [];
|
|
2261
|
+
};
|
|
2262
|
+
|
|
2263
|
+
|
|
2264
|
+
const fetchBilibiliCovers = async () => {
|
|
2265
|
+
try {
|
|
2266
|
+
const url = `https://api.bilibili.com/x/web-interface/search/all/v2?keyword=${encodeURIComponent(effectiveQuery)}`;
|
|
2267
|
+
const res = await fetchWithTimeout(url, {
|
|
2268
|
+
headers: {
|
|
2269
|
+
...BROWSER_HEADERS,
|
|
2270
|
+
'Accept-Language': 'zh-CN,zh;q=0.9',
|
|
2271
|
+
'Referer': 'https://www.bilibili.com/'
|
|
2272
|
+
}
|
|
2273
|
+
}, 8000);
|
|
2274
|
+
if (res.ok) {
|
|
2275
|
+
const json = await res.json().catch(() => null);
|
|
2276
|
+
const vlist = (json?.data?.result || []).find(r => r.result_type === 'video')?.data || [];
|
|
2277
|
+
return vlist.slice(0, 5).map(v => {
|
|
2278
|
+
let pic = v.pic || '';
|
|
2279
|
+
if (pic.startsWith('//')) pic = 'https:' + pic;
|
|
2280
|
+
if (!pic.startsWith('http')) return null;
|
|
2281
|
+
return {
|
|
2282
|
+
title: cleanHtml(v.title || effectiveQuery),
|
|
2283
|
+
imageUrl: pic,
|
|
2284
|
+
thumbUrl: pic,
|
|
2285
|
+
sourcePageUrl: `https://www.bilibili.com/video/${v.bvid}`,
|
|
2286
|
+
width: null,
|
|
2287
|
+
height: null,
|
|
2288
|
+
source: 'Bilibili Covers'
|
|
2289
|
+
};
|
|
2290
|
+
}).filter(Boolean);
|
|
2291
|
+
}
|
|
2292
|
+
} catch {}
|
|
2293
|
+
return [];
|
|
2294
|
+
};
|
|
2295
|
+
|
|
2296
|
+
|
|
2297
|
+
const [baiduImgs, bingImgs, biliImgs] = await Promise.allSettled([
|
|
2298
|
+
fetchBaiduImages(),
|
|
2299
|
+
fetchBingImages(),
|
|
2300
|
+
fetchBilibiliCovers()
|
|
2301
|
+
]);
|
|
2302
|
+
|
|
2303
|
+
const bList = baiduImgs.status === 'fulfilled' && Array.isArray(baiduImgs.value) ? baiduImgs.value : [];
|
|
2304
|
+
const biList = bingImgs.status === 'fulfilled' && Array.isArray(bingImgs.value) ? bingImgs.value : [];
|
|
2305
|
+
const blList = biliImgs.status === 'fulfilled' && Array.isArray(biliImgs.value) ? biliImgs.value : [];
|
|
2306
|
+
|
|
2307
|
+
let idx = 0;
|
|
2308
|
+
while (results.length < capped && (idx < bList.length || idx < biList.length || idx < blList.length)) {
|
|
2309
|
+
if (idx < bList.length && results.length < capped) addImage(bList[idx], 'Baidu Images');
|
|
2310
|
+
if (idx < biList.length && results.length < capped) addImage(biList[idx], 'Bing Images');
|
|
2311
|
+
if (idx < blList.length && results.length < capped) addImage(blList[idx], 'Bilibili Covers');
|
|
2312
|
+
idx++;
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
if (results.length < capped) {
|
|
2316
|
+
try {
|
|
2317
|
+
const webRes = await searchChineseWeb(query, Math.min(capped - results.length, 3));
|
|
2318
|
+
const webResults = webRes?.results || [];
|
|
2319
|
+
const fetchedPages = await Promise.all(webResults.map(async r => {
|
|
2320
|
+
try {
|
|
2321
|
+
return { r, page: await fetchWebpage(r.url, 2000) };
|
|
2322
|
+
} catch {
|
|
2323
|
+
return { r, page: null };
|
|
2324
|
+
}
|
|
2325
|
+
}));
|
|
2326
|
+
for (const { r, page } of fetchedPages) {
|
|
2327
|
+
if (results.length >= capped) break;
|
|
2328
|
+
for (const img of page?.images || []) {
|
|
2329
|
+
if (results.length >= capped) break;
|
|
2330
|
+
if (img.src && img.src.startsWith('http')) {
|
|
2331
|
+
addImage({
|
|
2332
|
+
title: img.alt || r.title || effectiveQuery,
|
|
2333
|
+
imageUrl: img.src,
|
|
2334
|
+
thumbUrl: img.src,
|
|
2335
|
+
sourcePageUrl: r.url,
|
|
2336
|
+
width: null,
|
|
2337
|
+
height: null,
|
|
2338
|
+
source: `Web Portal (${r.source || 'Media'})`
|
|
2339
|
+
}, 'Web Portal');
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
} catch {}
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
return {
|
|
2347
|
+
status: results.length > 0 ? 'success' : 'no_results',
|
|
2348
|
+
query,
|
|
2349
|
+
count: results.length,
|
|
2350
|
+
sources: [...new Set(sources)],
|
|
2351
|
+
results: results.slice(0, capped)
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
export const BILIBILI_BVID_PATTERN = /^BV[0-9A-Za-z]{10}$/;
|
|
2356
|
+
export const BILIBILI_PLAYER_ORIGIN = 'https://player.bilibili.com';
|
|
2357
|
+
|
|
2358
|
+
|
|
2359
|
+
export function buildBilibiliEmbedUrl(bvid, { autoplay = false, page = 1, aid = '', cid = '' } = {}) {
|
|
2360
|
+
const id = String(bvid || '').trim();
|
|
2361
|
+
if (!BILIBILI_BVID_PATTERN.test(id)) return '';
|
|
2362
|
+
const digits = (v) => (/^\d+$/.test(String(v || '').trim()) ? String(v).trim() : '');
|
|
2363
|
+
const aidDigits = digits(aid);
|
|
2364
|
+
const cidDigits = digits(cid);
|
|
2365
|
+
const pageNum = Number(page) >= 1 ? Math.floor(Number(page)) : 1;
|
|
2366
|
+
let params = `bvid=${encodeURIComponent(id)}&page=${pageNum}&high_quality=1&danmaku=0`;
|
|
2367
|
+
|
|
2368
|
+
if (aidDigits) params = `aid=${aidDigits}&${params}`;
|
|
2369
|
+
if (cidDigits) params += `&cid=${cidDigits}`;
|
|
2370
|
+
if (autoplay) params += '&autoplay=1';
|
|
2371
|
+
return `${BILIBILI_PLAYER_ORIGIN}/player.html?${params}`;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
function escapeBilibiliAttr(value) {
|
|
2375
|
+
return String(value ?? '')
|
|
2376
|
+
.replace(/&/g, '&')
|
|
2377
|
+
.replace(/"/g, '"')
|
|
2378
|
+
.replace(/</g, '<')
|
|
2379
|
+
.replace(/>/g, '>');
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
|
|
2383
|
+
export function buildBilibiliIframeHtml(bvid, { title = 'Bilibili Video', autoplay = false, cssClass = '', aid = '', cid = '', page = 1, width = '100%', height = '500' } = {}) {
|
|
2384
|
+
const src = buildBilibiliEmbedUrl(bvid, { autoplay, aid, cid, page });
|
|
2385
|
+
if (!src) return '';
|
|
2386
|
+
const cls = cssClass ? ` class="${escapeBilibiliAttr(cssClass)}"` : '';
|
|
2387
|
+
return `<iframe src="${src}"${cls} width="${escapeBilibiliAttr(width)}" height="${escapeBilibiliAttr(height)}" style="width:100%;aspect-ratio:16/9;min-height:360px;border:0;" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" title="${escapeBilibiliAttr(title)}" loading="lazy" referrerpolicy="no-referrer"></iframe>`;
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
|
|
2391
|
+
export function normalizeBilibiliIframeSrc(src, fallbackBvid = '') {
|
|
2392
|
+
const raw = String(src || '').trim();
|
|
2393
|
+
const fallback = String(fallbackBvid || '').trim();
|
|
2394
|
+
|
|
2395
|
+
const fromFallback = fallback && BILIBILI_BVID_PATTERN.test(fallback)
|
|
2396
|
+
? buildBilibiliEmbedUrl(fallback)
|
|
2397
|
+
: '';
|
|
2398
|
+
|
|
2399
|
+
if (!raw || raw.includes('://:') || /^(\/\/)?:?(\/\/)?:?bilibili\.com\/?$/.test(raw)) {
|
|
2400
|
+
return fromFallback;
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
let candidate = raw;
|
|
2404
|
+
if (candidate.startsWith('//')) candidate = `https:${candidate}`;
|
|
2405
|
+
|
|
2406
|
+
let url;
|
|
2407
|
+
try {
|
|
2408
|
+
url = new URL(candidate, BILIBILI_PLAYER_ORIGIN);
|
|
2409
|
+
} catch {
|
|
2410
|
+
return fromFallback;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
if (url.hostname !== 'player.bilibili.com') return fromFallback;
|
|
2414
|
+
|
|
2415
|
+
const bvid = (url.searchParams.get('bvid') || '').trim();
|
|
2416
|
+
if (BILIBILI_BVID_PATTERN.test(bvid)) {
|
|
2417
|
+
return buildBilibiliEmbedUrl(bvid, {
|
|
2418
|
+
aid: url.searchParams.get('aid') || '',
|
|
2419
|
+
cid: url.searchParams.get('cid') || '',
|
|
2420
|
+
page: url.searchParams.get('page') || url.searchParams.get('p') || 1,
|
|
2421
|
+
autoplay: url.searchParams.get('autoplay') === '1',
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
return fromFallback;
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
export function isBrokenBilibiliIframeSrc(src) {
|
|
2428
|
+
const raw = String(src || '').trim();
|
|
2429
|
+
if (!raw) return true;
|
|
2430
|
+
if (raw.includes('://:')) return true;
|
|
2431
|
+
const normalized = normalizeBilibiliIframeSrc(raw);
|
|
2432
|
+
return normalized === '';
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
|
|
2436
|
+
export function normalizeBilibiliPic(pic) {
|
|
2437
|
+
const raw = String(pic || '').trim();
|
|
2438
|
+
if (!raw) return '';
|
|
2439
|
+
if (raw.startsWith('//')) return `https:${raw}`;
|
|
2440
|
+
if (/^http:\/\//i.test(raw)) return raw.replace(/^http:\/\//i, 'https://');
|
|
2441
|
+
if (/^https:\/\//i.test(raw)) return raw;
|
|
2442
|
+
return '';
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
|
|
2446
|
+
export function parseBilibiliViewPayload(json) {
|
|
2447
|
+
const d = json?.data;
|
|
2448
|
+
if (json?.code !== 0 || !d || !d.bvid) return null;
|
|
2449
|
+
return {
|
|
2450
|
+
title: cleanHtml(d.title || '') || d.bvid,
|
|
2451
|
+
author: d.owner?.name || '',
|
|
2452
|
+
pic: normalizeBilibiliPic(d.pic || ''),
|
|
2453
|
+
duration: Number(d.duration) || 0,
|
|
2454
|
+
publishedAt: d.pubdate ? new Date(d.pubdate * 1000).toISOString() : '',
|
|
2455
|
+
playCount: Number(d.stat?.view) || 0,
|
|
2456
|
+
danmakuCount: Number(d.stat?.danmaku) || 0,
|
|
2457
|
+
cid: d.cid ? String(d.cid) : '',
|
|
2458
|
+
};
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
|
|
2462
|
+
export function parseBilibiliSearchPayload(json, bvid) {
|
|
2463
|
+
const results = Array.isArray(json?.data?.result) ? json.data.result : [];
|
|
2464
|
+
const exact = results.find(r => r && r.bvid === bvid);
|
|
2465
|
+
if (!exact) return null;
|
|
2466
|
+
return {
|
|
2467
|
+
title: cleanHtml(exact.title || '') || bvid,
|
|
2468
|
+
author: exact.author || '',
|
|
2469
|
+
pic: normalizeBilibiliPic(exact.pic || ''),
|
|
2470
|
+
duration: 0,
|
|
2471
|
+
publishedAt: exact.pubdate ? new Date(exact.pubdate * 1000).toISOString() : '',
|
|
2472
|
+
playCount: Number(exact.play) || 0,
|
|
2473
|
+
danmakuCount: Number(exact.video_review) || 0,
|
|
2474
|
+
cid: '',
|
|
2475
|
+
};
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
function buildBilibiliVideoPayload(id, parsed) {
|
|
2479
|
+
return {
|
|
2480
|
+
title: parsed.title || id,
|
|
2481
|
+
author: parsed.author || '',
|
|
2482
|
+
pic: parsed.pic || '',
|
|
2483
|
+
duration: parsed.duration || 0,
|
|
2484
|
+
publishedAt: parsed.publishedAt || '',
|
|
2485
|
+
playCount: parsed.playCount || 0,
|
|
2486
|
+
danmakuCount: parsed.danmakuCount || 0,
|
|
2487
|
+
cid: parsed.cid || '',
|
|
2488
|
+
url: `https://www.bilibili.com/video/${id}`,
|
|
2489
|
+
embedUrl: buildBilibiliEmbedUrl(id, { cid: parsed.cid ? String(parsed.cid) : '' }),
|
|
2490
|
+
};
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
const BILIBILI_INFO_CACHE = new BoundedLruCache(500);
|
|
2494
|
+
const BILIBILI_INFO_TTL_MS = 10 * 60_000;
|
|
2495
|
+
const BILIBILI_INFO_FAIL_TTL_MS = 60_000;
|
|
2496
|
+
|
|
2497
|
+
async function bilibiliApiCookie() {
|
|
2498
|
+
try {
|
|
2499
|
+
const home = await fetchWithTimeout('https://www.bilibili.com/', { headers: BROWSER_HEADERS }, 8000);
|
|
2500
|
+
const setCookies = home.headers.getSetCookie?.() || [];
|
|
2501
|
+
return setCookies.map(c => c.split(';')[0]).join('; ');
|
|
2502
|
+
} catch {
|
|
2503
|
+
return '';
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
export async function fetchBilibiliVideoInfo(bvid) {
|
|
2508
|
+
const id = String(bvid || '').trim();
|
|
2509
|
+
if (!BILIBILI_BVID_PATTERN.test(id)) {
|
|
2510
|
+
return { status: 'error', message: 'A valid Bilibili bvid (BV + 10 chars) is required.' };
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
const cached = BILIBILI_INFO_CACHE.get(id);
|
|
2514
|
+
if (cached) return cached;
|
|
2515
|
+
const remember = (value, ttlMs) => {
|
|
2516
|
+
BILIBILI_INFO_CACHE.set(id, value, ttlMs);
|
|
2517
|
+
return value;
|
|
2518
|
+
};
|
|
2519
|
+
|
|
2520
|
+
const cookie = await bilibiliApiCookie();
|
|
2521
|
+
const apiHeaders = {
|
|
2522
|
+
...BROWSER_HEADERS,
|
|
2523
|
+
'Accept-Language': 'zh-CN,zh;q=0.9',
|
|
2524
|
+
...(cookie ? { Cookie: cookie } : {}),
|
|
2525
|
+
};
|
|
2526
|
+
|
|
2527
|
+
try {
|
|
2528
|
+
|
|
2529
|
+
const viewRes = await fetchWithTimeout(`https://api.bilibili.com/x/web-interface/view?bvid=${encodeURIComponent(id)}`, {
|
|
2530
|
+
headers: { ...apiHeaders, 'Referer': `https://www.bilibili.com/video/${id}` }
|
|
2531
|
+
}, 12000);
|
|
2532
|
+
const viewJson = await viewRes.json().catch(() => null);
|
|
2533
|
+
const viewParsed = parseBilibiliViewPayload(viewJson);
|
|
2534
|
+
if (viewParsed) {
|
|
2535
|
+
return remember({ status: 'success', bvid: id, video: buildBilibiliVideoPayload(id, viewParsed) }, BILIBILI_INFO_TTL_MS);
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
|
|
2539
|
+
const searchRes = await fetchWithTimeout(`https://api.bilibili.com/x/web-interface/search/type?search_type=video&keyword=${encodeURIComponent(id)}&page=1&page_size=20`, {
|
|
2540
|
+
headers: { ...apiHeaders, 'Referer': 'https://search.bilibili.com/' }
|
|
2541
|
+
}, 12000);
|
|
2542
|
+
const searchJson = await searchRes.json().catch(() => null);
|
|
2543
|
+
const searchParsed = parseBilibiliSearchPayload(searchJson, id);
|
|
2544
|
+
if (searchParsed) {
|
|
2545
|
+
return remember({ status: 'success', bvid: id, video: buildBilibiliVideoPayload(id, searchParsed) }, BILIBILI_INFO_TTL_MS);
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
return remember({
|
|
2549
|
+
status: 'error',
|
|
2550
|
+
bvid: id,
|
|
2551
|
+
message: `Bilibili metadata unavailable for ${id} (view API blocked by risk control, search fallback had no exact match).`
|
|
2552
|
+
}, BILIBILI_INFO_FAIL_TTL_MS);
|
|
2553
|
+
} catch (err) {
|
|
2554
|
+
return remember({
|
|
2555
|
+
status: 'error',
|
|
2556
|
+
bvid: id,
|
|
2557
|
+
message: err.name === 'AbortError' ? 'Bilibili request timed out (12s)' : err.message
|
|
2558
|
+
}, BILIBILI_INFO_FAIL_TTL_MS);
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
let cachedBilibiliCookie = '';
|
|
2563
|
+
let bilibiliCookieExpiresAt = 0;
|
|
2564
|
+
let bilibiliCookieFetchPromise = null;
|
|
2565
|
+
|
|
2566
|
+
async function getBilibiliCookie() {
|
|
2567
|
+
const now = Date.now();
|
|
2568
|
+
if (cachedBilibiliCookie && now < bilibiliCookieExpiresAt) {
|
|
2569
|
+
return cachedBilibiliCookie;
|
|
2570
|
+
}
|
|
2571
|
+
if (bilibiliCookieFetchPromise) {
|
|
2572
|
+
return bilibiliCookieFetchPromise;
|
|
2573
|
+
}
|
|
2574
|
+
bilibiliCookieFetchPromise = (async () => {
|
|
2575
|
+
try {
|
|
2576
|
+
const home = await fetchWithTimeout('https://www.bilibili.com/', { headers: BROWSER_HEADERS }, 8000);
|
|
2577
|
+
const setCookies = home.headers.getSetCookie?.() || [];
|
|
2578
|
+
const cookie = setCookies.map(c => c.split(';')[0]).join('; ');
|
|
2579
|
+
if (cookie) {
|
|
2580
|
+
cachedBilibiliCookie = cookie;
|
|
2581
|
+
bilibiliCookieExpiresAt = Date.now() + 3600_000;
|
|
2582
|
+
}
|
|
2583
|
+
return cookie;
|
|
2584
|
+
} catch {
|
|
2585
|
+
return cachedBilibiliCookie || '';
|
|
2586
|
+
} finally {
|
|
2587
|
+
bilibiliCookieFetchPromise = null;
|
|
2588
|
+
}
|
|
2589
|
+
})();
|
|
2590
|
+
return bilibiliCookieFetchPromise;
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
export async function searchBilibili(keyword, maxResults = 8) {
|
|
2594
|
+
if (!keyword || typeof keyword !== 'string') {
|
|
2595
|
+
return { status: 'error', message: 'Keyword is required for Bilibili search.' };
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
const effectiveKeyword = getContextualizedSearchQuery(keyword, 'bilibili');
|
|
2599
|
+
const capped = Math.min(Math.max(maxResults, 1), 20);
|
|
2600
|
+
|
|
2601
|
+
try {
|
|
2602
|
+
const cookie = await getBilibiliCookie();
|
|
2603
|
+
|
|
2604
|
+
const apiUrl = `https://api.bilibili.com/x/web-interface/search/type?search_type=video&keyword=${encodeURIComponent(effectiveKeyword)}&page=1&page_size=${capped}`;
|
|
2605
|
+
const res = await fetchWithTimeout(apiUrl, {
|
|
2606
|
+
headers: {
|
|
2607
|
+
...BROWSER_HEADERS,
|
|
2608
|
+
'Accept-Language': 'zh-CN,zh;q=0.9',
|
|
2609
|
+
'Referer': 'https://search.bilibili.com/',
|
|
2610
|
+
...(cookie ? { Cookie: cookie } : {})
|
|
2611
|
+
}
|
|
2612
|
+
}, 12000);
|
|
2613
|
+
if (!res.ok) {
|
|
2614
|
+
return {
|
|
2615
|
+
status: 'error',
|
|
2616
|
+
keyword,
|
|
2617
|
+
searchQuery: effectiveKeyword,
|
|
2618
|
+
message: `Bilibili API returned HTTP ${res.status}. Fallback: search_chinese_web with site:bilibili.com`
|
|
2619
|
+
};
|
|
2620
|
+
}
|
|
2621
|
+
const json = await res.json();
|
|
2622
|
+
if (json?.code !== 0) {
|
|
2623
|
+
return {
|
|
2624
|
+
status: 'error',
|
|
2625
|
+
keyword,
|
|
2626
|
+
searchQuery: effectiveKeyword,
|
|
2627
|
+
message: `Bilibili API returned code ${json?.code} (${json?.message || 'unknown'}). This is usually anti-bot protection — fallback: search_chinese_web with site:bilibili.com`
|
|
2628
|
+
};
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
const items = (json.data?.result || []).slice(0, capped).map(r => {
|
|
2632
|
+
const bvid = r.bvid || '';
|
|
2633
|
+
const rawPic = r.pic || '';
|
|
2634
|
+
const pic = rawPic.startsWith('//') ? `https:${rawPic}` : rawPic;
|
|
2635
|
+
const cleanTitle = cleanHtml(r.title || '');
|
|
2636
|
+
const author = r.author || '';
|
|
2637
|
+
const play = Number(r.play) || 0;
|
|
2638
|
+
const danmaku = Number(r.video_review) || 0;
|
|
2639
|
+
|
|
2640
|
+
let rankScore = 0;
|
|
2641
|
+
const lowerAuthor = author.toLowerCase();
|
|
2642
|
+
const lowerTitle = cleanTitle.toLowerCase();
|
|
2643
|
+
const rawDesc = String(r.description || '');
|
|
2644
|
+
const lowerDesc = rawDesc.toLowerCase();
|
|
2645
|
+
|
|
2646
|
+
|
|
2647
|
+
const hasArtistInAuthor = lowerAuthor.includes('鞠婧祎工作室') || lowerAuthor.includes('鞠婧祎') || lowerAuthor.includes('studio');
|
|
2648
|
+
const hasArtistInTitle = cleanTitle.includes('鞠婧祎') || cleanTitle.includes('小鞠') || lowerTitle.includes('kiku') || lowerTitle.includes('jujingyi');
|
|
2649
|
+
const hasArtistInDesc = rawDesc.includes('鞠婧祎') || rawDesc.includes('小鞠');
|
|
2650
|
+
|
|
2651
|
+
if (hasArtistInAuthor) {
|
|
2652
|
+
rankScore += 100;
|
|
2653
|
+
} else if (hasArtistInTitle) {
|
|
2654
|
+
rankScore += 80;
|
|
2655
|
+
} else if (hasArtistInDesc) {
|
|
2656
|
+
rankScore += 45;
|
|
2657
|
+
} else {
|
|
2658
|
+
|
|
2659
|
+
rankScore -= 40;
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
|
|
2663
|
+
if (lowerAuthor.includes('snh48') || lowerAuthor.includes('腾讯') || lowerAuthor.includes('优酷') || lowerAuthor.includes('爱奇艺') || lowerAuthor.includes('芒果') || lowerAuthor.includes('帕莎') || lowerAuthor.includes('prsr') || lowerAuthor.includes('官方')) {
|
|
2664
|
+
rankScore += 60;
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
|
|
2668
|
+
if (lowerTitle.includes('mv') || cleanTitle.includes('官方') || lowerTitle.includes('official') || cleanTitle.includes('舞台') || cleanTitle.includes('现场') || cleanTitle.includes('代言') || cleanTitle.includes('花絮') || cleanTitle.includes('广告') || lowerTitle.includes('prsr') || cleanTitle.includes('帕莎')) {
|
|
2669
|
+
rankScore += 35;
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
if (play > 0) rankScore += Math.min(Math.log10(play) * 10, 50);
|
|
2673
|
+
|
|
2674
|
+
return {
|
|
2675
|
+
title: cleanTitle,
|
|
2676
|
+
bvid,
|
|
2677
|
+
author,
|
|
2678
|
+
duration: r.duration || '',
|
|
2679
|
+
publishedAt: r.pubdate ? new Date(r.pubdate * 1000).toISOString() : '',
|
|
2680
|
+
playCount: play,
|
|
2681
|
+
danmakuCount: danmaku,
|
|
2682
|
+
pic,
|
|
2683
|
+
embedUrl: bvid ? buildBilibiliEmbedUrl(bvid) : '',
|
|
2684
|
+
description: rawDesc.slice(0, 300),
|
|
2685
|
+
url: bvid ? `https://www.bilibili.com/video/${bvid}` : '',
|
|
2686
|
+
rankScore: Math.round(rankScore * 10) / 10
|
|
2687
|
+
};
|
|
2688
|
+
}).filter(r => r.title && r.url);
|
|
2689
|
+
|
|
2690
|
+
items.sort((a, b) => (b.rankScore || 0) - (a.rankScore || 0));
|
|
2691
|
+
|
|
2692
|
+
return {
|
|
2693
|
+
status: items.length > 0 ? 'success' : 'no_results',
|
|
2694
|
+
keyword,
|
|
2695
|
+
searchQuery: effectiveKeyword,
|
|
2696
|
+
count: items.length,
|
|
2697
|
+
source: 'Bilibili',
|
|
2698
|
+
results: items
|
|
2699
|
+
};
|
|
2700
|
+
} catch (err) {
|
|
2701
|
+
return {
|
|
2702
|
+
status: 'error',
|
|
2703
|
+
keyword,
|
|
2704
|
+
message: err.name === 'AbortError' ? 'Bilibili request timed out (12s)' : err.message
|
|
2705
|
+
};
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
export async function webExtract(query, opts = {}) {
|
|
2710
|
+
if (!query || typeof query !== 'string') {
|
|
714
2711
|
return { status: 'error', message: 'Query string is required.' };
|
|
715
2712
|
}
|
|
716
2713
|
|
|
@@ -749,7 +2746,7 @@ export async function webExtract(query, opts = {}) {
|
|
|
749
2746
|
const toFetch = candidates.slice(0, cappedResults);
|
|
750
2747
|
const fetchPromises = toFetch.map(async (result) => {
|
|
751
2748
|
try {
|
|
752
|
-
const page = await fetchWebpage(result.url, maxContentLength);
|
|
2749
|
+
const page = await fetchWebpage(result.url, maxContentLength, { query });
|
|
753
2750
|
return {
|
|
754
2751
|
title: result.title || page.meta?.title || '',
|
|
755
2752
|
url: result.url,
|
|
@@ -784,22 +2781,31 @@ export async function webExtract(query, opts = {}) {
|
|
|
784
2781
|
|
|
785
2782
|
const fetched = await Promise.all(fetchPromises);
|
|
786
2783
|
|
|
787
|
-
|
|
2784
|
+
const sharedHashState = { hashes: [] };
|
|
788
2785
|
const combinedContent = fetched
|
|
789
2786
|
.filter(r => r.fetchStatus === 'success' && r.content)
|
|
790
2787
|
.map(r => {
|
|
791
2788
|
const metaLine = r.meta?.description ? `\nDescription: ${r.meta.description}` : '';
|
|
792
2789
|
const headingsLine = r.headings?.length ? `\nHeadings: ${r.headings.map(h => h.text).join(' | ')}` : '';
|
|
793
|
-
|
|
2790
|
+
const passages = filterUniquePassages(splitIntoPassages(r.content), sharedHashState);
|
|
2791
|
+
const body = truncateByWords(passages.map(p => p.text).join('\n\n'), maxContentLength);
|
|
2792
|
+
return `### ${r.title}\nSource: ${r.url}${metaLine}${headingsLine}\n\n${body}`;
|
|
794
2793
|
})
|
|
795
2794
|
.join('\n\n---\n\n');
|
|
796
2795
|
|
|
2796
|
+
const pagesSucceeded = fetched.filter(r => r.fetchStatus === 'success').length;
|
|
2797
|
+
const status = fetched.length === 0
|
|
2798
|
+
? 'no_results'
|
|
2799
|
+
: pagesSucceeded === 0 || !combinedContent.length
|
|
2800
|
+
? 'no_content'
|
|
2801
|
+
: 'success';
|
|
2802
|
+
|
|
797
2803
|
return {
|
|
798
|
-
status
|
|
2804
|
+
status,
|
|
799
2805
|
query,
|
|
800
2806
|
language: searchResult.language,
|
|
801
2807
|
pagesFetched: fetched.length,
|
|
802
|
-
pagesSucceeded
|
|
2808
|
+
pagesSucceeded,
|
|
803
2809
|
combinedContentLength: combinedContent.length,
|
|
804
2810
|
pages: fetched.map(r => ({
|
|
805
2811
|
title: r.title,
|