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.
Files changed (131) hide show
  1. package/README.md +459 -407
  2. package/package.json +32 -3
  3. package/server/agent/agentCore.js +635 -267
  4. package/server/agent/appPaths.js +2 -0
  5. package/server/agent/catalogEntityResolver.js +637 -0
  6. package/server/agent/citations.js +0 -21
  7. package/server/agent/clarificationAnalytics.js +1 -1
  8. package/server/agent/clarifyingQuestions.js +286 -216
  9. package/server/agent/cli.js +532 -73
  10. package/server/agent/commandFeatures.js +123 -24
  11. package/server/agent/commandRegistry.js +128 -62
  12. package/server/agent/commands.js +1410 -95
  13. package/server/agent/config.js +253 -163
  14. package/server/agent/contextCompactor.js +239 -25
  15. package/server/agent/dynamicCatalog.js +192 -0
  16. package/server/agent/envBootstrap.js +46 -0
  17. package/server/agent/envManager.js +317 -0
  18. package/server/agent/eventBus.js +3 -3
  19. package/server/agent/feedbackLearning.js +0 -8
  20. package/server/agent/gpuMetrics.js +188 -55
  21. package/server/agent/ingestionWorker.js +339 -0
  22. package/server/agent/ink/App.jsx +348 -0
  23. package/server/agent/ink/Panel.jsx +54 -0
  24. package/server/agent/ink/PromptInput.jsx +321 -0
  25. package/server/agent/ink/TaskPlanPanel.jsx +88 -0
  26. package/server/agent/ink/ThinkingPanel.jsx +68 -0
  27. package/server/agent/ink/ToolResult.jsx +133 -0
  28. package/server/agent/ink/main.jsx +195 -0
  29. package/server/agent/ink/useEventBus.js +218 -0
  30. package/server/agent/lanDiscovery.js +369 -0
  31. package/server/agent/llmClient.js +111 -39
  32. package/server/agent/lmStudioWakeup.js +144 -0
  33. package/server/agent/mcpClient.js +2 -2
  34. package/server/agent/memory/contextAllocator.js +214 -0
  35. package/server/agent/memory/hierarchicalStore.js +523 -0
  36. package/server/agent/memory/reflectionWorker.js +110 -0
  37. package/server/agent/memory.js +635 -494
  38. package/server/agent/modelConfig.js +487 -0
  39. package/server/agent/newsIntelligence.js +219 -0
  40. package/server/agent/newsWatch.js +85 -0
  41. package/server/agent/prompts.js +146 -0
  42. package/server/agent/publicTools.js +7 -0
  43. package/server/agent/queryDetails.js +62 -1
  44. package/server/agent/research.js +314 -35
  45. package/server/agent/routingInterceptor.js +77 -0
  46. package/server/agent/rumoredProjects.js +15 -0
  47. package/server/agent/sanitizer.js +117 -17
  48. package/server/agent/subagent.js +52 -9
  49. package/server/agent/suggestionTui.js +33 -13
  50. package/server/agent/suggestions.js +29 -13
  51. package/server/agent/telemetry/hardwareTiers.js +332 -0
  52. package/server/agent/telemetry/runtimeWatchdog.js +66 -0
  53. package/server/agent/telemetry/techPowerUpClient.js +682 -0
  54. package/server/agent/telemetry/techpowerup-mcp.json +22 -0
  55. package/server/agent/telemetry.js +0 -11
  56. package/server/agent/terminalImage.js +0 -1
  57. package/server/agent/textToolProtocol.js +190 -0
  58. package/server/agent/tokenTracker.js +220 -0
  59. package/server/agent/toolRouter.js +362 -216
  60. package/server/agent/tools.js +1155 -194
  61. package/server/agent/ui.js +144 -41
  62. package/server/agent/vramService.js +572 -0
  63. package/server/agent/webSearch.js +2279 -273
  64. package/server/agent/websearch/adapters/duckduckgo.js +97 -0
  65. package/server/agent/websearch/adapters/jina.js +77 -0
  66. package/server/agent/websearch/adapters/searxng.js +57 -0
  67. package/server/agent/websearch/core/canonical.js +89 -0
  68. package/server/agent/websearch/core/fusion.js +81 -0
  69. package/server/agent/websearch/core/types.js +29 -0
  70. package/server/agent/websearch/extract.js +605 -0
  71. package/server/agent/websearch/index.js +43 -0
  72. package/server/agent/websearch/orchestrator.js +266 -0
  73. package/server/agent/websearch/resilience/breaker.js +97 -0
  74. package/server/agent/websearch/resilience/cache.js +59 -0
  75. package/server/agent/websearch/resilience/limiter.js +93 -0
  76. package/server/agent/websearch/security/filter.js +36 -0
  77. package/server/agent/websearch/security/ssrf.js +289 -0
  78. package/server/agent/websearch/telemetry.js +71 -0
  79. package/server/agent/youtubeAuth.js +82 -73
  80. package/server/agent/youtubeTranscript.js +209 -0
  81. package/server/apiRouter.js +390 -190
  82. package/server/mcp/data/brands.js +5 -5
  83. package/server/mcp/data/discography.js +56 -45
  84. package/server/mcp/data/gallery.js +9 -0
  85. package/server/mcp/data/index.js +1 -0
  86. package/server/mcp/data/industryIndex.js +251 -0
  87. package/server/mcp/data/profile.js +5 -5
  88. package/server/mcp/data/timeline.js +2 -2
  89. package/server/mcp/data/works.js +2 -2
  90. package/server/mcp/handler.js +0 -2
  91. package/server/mcp/prompts.js +42 -2
  92. package/server/mcp/resources.js +5 -1
  93. package/server/mcp/security.js +25 -14
  94. package/server/mcp/server.js +92 -3
  95. package/server/mcp/tools.js +20 -11
  96. package/server/routes/agent.js +55 -0
  97. package/server/web-chat/core/connection.js +218 -194
  98. package/server/web-chat/core/conversationContext.js +1 -1
  99. package/server/web-chat/core/directCatalogEngine.js +190 -184
  100. package/server/web-chat/core/followUpEngine.js +88 -11
  101. package/server/web-chat/core/security.js +7 -4
  102. package/server/web-chat/router.js +54 -17
  103. package/server/web-chat/routes/bilibiliRoute.js +34 -0
  104. package/server/web-chat/routes/chatRoute.js +488 -397
  105. package/server/web-chat/routes/clarifyConfigRoute.js +1 -2
  106. package/server/web-chat/routes/exportRoute.js +2 -1
  107. package/server/web-chat/routes/lanDiscoveryRoute.js +90 -0
  108. package/server/web-chat/routes/newsRoute.js +36 -6
  109. package/server/web-chat/routes/providerRoute.js +87 -0
  110. package/server/web-chat/routes/staticRoute.js +61 -13
  111. package/server/web-chat/routes/statusRoute.js +23 -2
  112. package/server/web-chat/routes/vramRoute.js +22 -0
  113. package/server/web-chat/server.js +135 -106
  114. package/server/web-chat/ui/htmlBuilder.js +8 -8
  115. package/server/web-chat/ui/previews/modelSettings.preview.html +41 -0
  116. package/server/web-chat/ui/scripts/agentBubbleSystem.js +0 -5
  117. package/server/web-chat/ui/scripts/client.js +10526 -8397
  118. package/server/web-chat/ui/scripts/webchatI18n.js +190 -138
  119. package/server/web-chat/ui/styles/main.css +7471 -4246
  120. package/server/web-chat/ui/templates/body.html +305 -179
  121. package/src/data/brandsData.js +29 -6
  122. package/src/data/discographyData.js +918 -900
  123. package/src/data/galleryData.js +66 -0
  124. package/src/data/industryIndexData.js +292 -0
  125. package/src/data/musicVideoData.js +48 -5
  126. package/src/data/profileData.js +15 -1
  127. package/src/data/timelineData.js +4 -3
  128. package/src/data/worksData.js +536 -535
  129. package/src/lib/vram/estimator.js +464 -0
  130. package/src/lib/vram/modelRegistry.js +387 -0
  131. 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 { assertSafeUrlHostname, sanitizeUrl } from './sanitizer.js';
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
- let _cheerioLoad = null;
9
- async function loadCheerio() {
10
- if (_cheerioLoad) return _cheerioLoad;
11
- try {
12
- const mod = await import('cheerio');
13
- _cheerioLoad = mod.load;
14
- return _cheerioLoad;
15
- } catch {
16
- return null;
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 html
23
- .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, ' ')
24
- .replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, ' ')
25
- .replace(/<nav\b[^<]*(?:(?!<\/nav>)<[^<]*)*<\/nav>/gi, ' ')
26
- .replace(/<footer\b[^<]*(?:(?!<\/footer>)<[^<]*)*<\/footer>/gi, ' ')
27
- .replace(/<header\b[^<]*(?:(?!<\/header>)<[^<]*)*<\/header>/gi, ' ')
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(/&nbsp;/g, ' ')
33
- .replace(/&amp;/g, '&')
34
- .replace(/&lt;/g, '<')
35
- .replace(/&gt;/g, '>')
36
- .replace(/&quot;/g, '"')
37
- .replace(/&#39;/g, "'")
38
- .replace(/&mdash;/g, '—')
39
- .replace(/&ndash;/g, '–')
40
- .replace(/&hellip;/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, redirect: 'manual' };
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 response = await fetch(sanitized.url.href, currentOptions);
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, sanitized.url).href;
270
+ const nextUrl = new URL(location, parsedUrl).href;
65
271
  const nextOrigin = new URL(nextUrl).origin;
66
- const headers = new Headers(currentOptions.headers || {});
67
- if (nextOrigin !== sanitized.url.origin) {
68
- headers.delete('authorization');
69
- headers.delete('cookie');
70
- headers.delete('proxy-authorization');
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.delete('content-type');
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 readResponseTextLimited(response, maxBytes) {
85
- const limit = Math.max(1024, Math.min(Number(maxBytes) || 1024, 2_000_000));
86
- const declared = Number(response.headers.get('content-length') || 0);
87
- if (declared > limit) throw new Error(`Response exceeds ${limit} byte limit`);
88
- if (!response.body?.getReader) {
89
- const text = await response.text();
90
- if (Buffer.byteLength(text, 'utf8') > limit) throw new Error(`Response exceeds ${limit} byte limit`);
91
- return text;
92
- }
93
- const reader = response.body.getReader();
94
- const decoder = new TextDecoder();
95
- let bytes = 0;
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
- while (true) {
99
- const { done, value } = await reader.read();
100
- if (done) break;
101
- bytes += value.byteLength;
102
- if (bytes > limit) throw new Error(`Response exceeds ${limit} byte limit`);
103
- text += decoder.decode(value, { stream: true });
104
- }
105
- text += decoder.decode();
106
- return text;
107
- } finally {
108
- if (bytes > limit) await reader.cancel().catch(() => {});
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
- 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;
166
- let match;
167
- while ((match = resultRegex.exec(html)) !== null && results.length < maxResults) {
168
- let rawLink = match[1];
169
- if (rawLink.includes('uddg=')) {
170
- try {
171
- const decoded = new URL('https://duckduckgo.com' + rawLink).searchParams.get('uddg');
172
- if (decoded) rawLink = decoded;
173
- } catch {}
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
- const title = cleanHtml(match[2]);
177
- const snippet = cleanHtml(match[3]);
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
- if (title && snippet && !results.some(r => r.url === rawLink)) {
180
- results.push({
181
- title,
182
- url: rawLink,
183
- snippet,
184
- source: 'DuckDuckGo'
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=${detectLanguage(query) === 'th' ? 'th' : detectLanguage(query) === 'zh' ? 'zh-cn' : 'en-us'}`;
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/122.0.0.0 Safari/537.36',
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': 'th,en-US;q=0.9,en;q=0.8,zh-CN;q=0.7,zh;q=0.6'
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.text();
249
-
250
- const linkRegex = /<h2><a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<p[^>]*class="b_lineclamp2 b_algoSlug"[^>]*>([\s\S]*?)<\/p>/gi;
251
- let match;
252
- while ((match = linkRegex.exec(html)) !== null && results.length < maxResults) {
253
- const rawLink = match[1];
254
- const title = cleanHtml(match[2]);
255
- const snippet = cleanHtml(match[3]);
256
-
257
- if (title && snippet && !results.some(r => r.url === rawLink)) {
258
- results.push({
259
- title,
260
- url: rawLink,
261
- snippet,
262
- source: 'Bing'
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 (err) {
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 results = [];
280
- const sources = [];
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 ddgResults = await searchDuckDuckGo(query, maxResults);
284
- ddgResults.forEach(r => {
285
- if (!results.some(existing => existing.url === r.url)) {
286
- results.push(r);
287
- sources.push('DuckDuckGo');
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
- if (results.length < maxResults) {
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 wikiResults = await searchWikipedia(query, maxResults - results.length, lang);
295
- wikiResults.forEach(r => {
296
- if (!results.some(existing => existing.url === r.url)) {
297
- results.push(r);
298
- sources.push('Wikipedia');
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 expandedQueries = expandQuery(query, lang);
307
- for (const expandedQuery of expandedQueries) {
308
- if (results.length >= maxResults) break;
309
- const ddgResults = await searchDuckDuckGo(expandedQuery, Math.ceil(maxResults / 2));
310
- ddgResults.forEach(r => {
311
- if (!results.some(existing => existing.url === r.url)) {
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
- if (results.length < maxResults) {
791
+ try {
792
+ let targetUrl = url;
321
793
  try {
322
- const bingResults = await searchBing(query, maxResults - results.length);
323
- bingResults.forEach(r => {
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
- return {
333
- status: results.length > 0 ? 'success' : 'no_results',
334
- query,
335
- language: lang,
336
- count: results.length,
337
- sources: [...new Set(sources)],
338
- results: results.slice(0, maxResults)
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
- const html = await readResponseTextLimited(res, Math.max(256_000, maxLength * 16));
394
- const cheerioLoad = await loadCheerio();
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
- let text = cleanHtml(html);
486
- if (text.length > maxLength) {
487
- text = text.slice(0, maxLength) + '... [Content truncated for brevity]';
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
- return {
491
- status: 'success',
492
- url: finalUrl,
493
- contentType: 'html',
494
- contentLength: text.length,
495
- content: text || 'Unable to retrieve text content from this webpage.'
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
- const tmpDir = await mkdtemp(join(tmpdir(), 'kiku-screenshot-'));
654
- const profileDir = join(tmpDir, 'chrome-profile');
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(url);
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
- try { await rm(tmpDir, { recursive: true, force: true }); } catch {}
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 async function webExtract(query, opts = {}) {
713
- if (!query || typeof query !== 'string') {
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(/&quot;/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, '&amp;')
2377
+ .replace(/"/g, '&quot;')
2378
+ .replace(/</g, '&lt;')
2379
+ .replace(/>/g, '&gt;');
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
- return `### ${r.title}\nSource: ${r.url}${metaLine}${headingsLine}\n\n${r.content.slice(0, maxContentLength)}`;
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: 'success',
2804
+ status,
799
2805
  query,
800
2806
  language: searchResult.language,
801
2807
  pagesFetched: fetched.length,
802
- pagesSucceeded: fetched.filter(r => r.fetchStatus === 'success').length,
2808
+ pagesSucceeded,
803
2809
  combinedContentLength: combinedContent.length,
804
2810
  pages: fetched.map(r => ({
805
2811
  title: r.title,