kiku-agent 1.3.2 → 1.3.4

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 (152) hide show
  1. package/README.md +69 -12
  2. package/package.json +2 -1
  3. package/server/agent/agentCore.js +319 -46
  4. package/server/agent/appPaths.js +31 -0
  5. package/server/agent/audioPlayer.js +339 -12
  6. package/server/agent/autoContext.js +193 -0
  7. package/server/agent/catalogEntityResolver.js +170 -28
  8. package/server/agent/clarifyingQuestions.js +137 -35
  9. package/server/agent/cli.js +177 -48
  10. package/server/agent/cloudAdapters.js +116 -0
  11. package/server/agent/commandRegistry.js +409 -80
  12. package/server/agent/commands.js +1056 -125
  13. package/server/agent/config.js +1088 -690
  14. package/server/agent/contextCompactor.js +213 -21
  15. package/server/agent/cpuMetrics.js +199 -0
  16. package/server/agent/engineService.js +591 -0
  17. package/server/agent/eval/modules/evaluation/runner.js +4 -14
  18. package/server/agent/eval/modules/evaluation/scoring.js +15 -3
  19. package/server/agent/eval/modules/server/evalApi.js +5 -5
  20. package/server/agent/eval/modules/ui/evalUi.js +7 -4
  21. package/server/agent/eventBus.js +1 -0
  22. package/server/agent/ink/main.jsx +196 -191
  23. package/server/agent/jobManager.js +11 -1
  24. package/server/agent/lanDiscovery.js +721 -23
  25. package/server/agent/llmClient.js +317 -71
  26. package/server/agent/lmStudioWakeup.js +517 -65
  27. package/server/agent/locationService.js +797 -0
  28. package/server/agent/mcpQuizData.js +509 -0
  29. package/server/agent/memory/contextAllocator.js +19 -7
  30. package/server/agent/memory.js +3 -1
  31. package/server/agent/modelCompatibility.js +352 -0
  32. package/server/agent/modelDefaults.js +1 -0
  33. package/server/agent/modelManager.js +46 -2
  34. package/server/agent/newsWatch.js +9 -3
  35. package/server/agent/processManager.js +229 -0
  36. package/server/agent/prompts.js +174 -8
  37. package/server/agent/routingInterceptor.js +9 -1
  38. package/server/agent/sanitizer.js +41 -6
  39. package/server/agent/searchQueryPlanner.js +202 -0
  40. package/server/agent/session.js +59 -4
  41. package/server/agent/tests/agentFollowUps.test.js +128 -2
  42. package/server/agent/tests/agentMusicControl.test.js +245 -0
  43. package/server/agent/tests/apiRouter.test.js +1 -1
  44. package/server/agent/tests/autoContext.test.js +176 -0
  45. package/server/agent/tests/bilibiliEmbed.test.js +45 -0
  46. package/server/agent/tests/chatHardening.test.js +121 -0
  47. package/server/agent/tests/chineseWebSearchEscalation.test.js +62 -0
  48. package/server/agent/tests/clarifyingQuestions.test.js +33 -0
  49. package/server/agent/tests/cliAutomation.test.js +38 -1
  50. package/server/agent/tests/cloudProviders.test.js +91 -3
  51. package/server/agent/tests/commands.test.js +92 -3
  52. package/server/agent/tests/contextIntelligence.test.js +50 -3
  53. package/server/agent/tests/cpuMetrics.test.js +101 -0
  54. package/server/agent/tests/decisionAndSearchEnhancements.test.js +32 -0
  55. package/server/agent/tests/edgeCasesScenarios.test.js +82 -0
  56. package/server/agent/tests/engineService.test.js +436 -0
  57. package/server/agent/tests/engineUnload.test.js +94 -0
  58. package/server/agent/tests/envCommand.test.js +69 -0
  59. package/server/agent/tests/evalMetrics.test.js +8 -0
  60. package/server/agent/tests/fetchApi.test.js +99 -0
  61. package/server/agent/tests/hybridWebSearch.test.js +2 -0
  62. package/server/agent/tests/lanDiscovery.test.js +5 -0
  63. package/server/agent/tests/lanInterfaces.test.js +510 -0
  64. package/server/agent/tests/lmStudioWakeup.test.js +278 -0
  65. package/server/agent/tests/locationTool.test.js +278 -0
  66. package/server/agent/tests/memoryRoute.test.js +173 -0
  67. package/server/agent/tests/memoryUiNormalization.test.js +75 -0
  68. package/server/agent/tests/modelCompatibility.test.js +148 -0
  69. package/server/agent/tests/modelManager.test.js +32 -0
  70. package/server/agent/tests/musicControlUiBridge.test.js +113 -0
  71. package/server/agent/tests/nextGenArchitecture.test.js +4 -2
  72. package/server/agent/tests/openDomainPrompt.test.js +197 -0
  73. package/server/agent/tests/openDomainSearch.test.js +121 -0
  74. package/server/agent/tests/processManager.test.js +144 -0
  75. package/server/agent/tests/providerPersistence.test.js +10 -5
  76. package/server/agent/tests/quickActionsAndModals.test.js +180 -0
  77. package/server/agent/tests/searchQueryPlanner.test.js +108 -0
  78. package/server/agent/tests/securityAuditRemediation.test.js +16 -9
  79. package/server/agent/tests/securityAuditRemediations2.test.js +154 -0
  80. package/server/agent/tests/securityHardening.test.js +4 -4
  81. package/server/agent/tests/sessionAndCopy.test.js +39 -4
  82. package/server/agent/tests/sidebarControls.test.js +34 -0
  83. package/server/agent/tests/statefulMemory.test.js +8 -1
  84. package/server/agent/tests/textProtocolLoop.test.js +71 -0
  85. package/server/agent/tests/textToolParsing.test.js +48 -0
  86. package/server/agent/tests/toolRoutingRegressions.test.js +41 -0
  87. package/server/agent/tests/unifiedEndpoints.test.js +13 -6
  88. package/server/agent/tests/v110Features.test.js +1 -1
  89. package/server/agent/tests/vramEstimator.test.js +75 -0
  90. package/server/agent/tests/webChatDocs.test.js +199 -0
  91. package/server/agent/tests/webChatInferenceControl.test.js +298 -0
  92. package/server/agent/tests/webChatInspection.test.js +256 -5
  93. package/server/agent/tests/webChatMcp.test.js +94 -0
  94. package/server/agent/tests/webExtract.test.js +23 -0
  95. package/server/agent/tests/webSources.test.js +46 -12
  96. package/server/agent/textToolProtocol.js +173 -46
  97. package/server/agent/toolRouter.js +318 -44
  98. package/server/agent/tools.js +877 -96
  99. package/server/agent/ui.js +39 -34
  100. package/server/agent/vramService.js +35 -54
  101. package/server/agent/webSearch.js +441 -162
  102. package/server/agent/websearch/orchestrator.js +15 -5
  103. package/server/agent/websearch/resilience/cache.js +9 -2
  104. package/server/agent/websearch/security/ssrf.js +41 -10
  105. package/server/agent/youtubeAuth.js +5 -20
  106. package/server/apiRouter.js +5558 -4422
  107. package/server/mcp/data/discography.js +2 -2
  108. package/server/mcp/examples/antigravity.example.json +17 -0
  109. package/server/mcp/handler.js +8 -4
  110. package/server/mcp/security.js +220 -21
  111. package/server/mcp/server.js +2 -1
  112. package/server/mcp/tests/connection.test.js +14 -8
  113. package/server/mcp/tests/mcpKeyAuth.test.js +156 -0
  114. package/server/mcp/tools.js +7 -1
  115. package/server/supabase_migration_prefix.sql +102 -0
  116. package/server/supabase_schema.sql +18 -18
  117. package/server/web-chat/core/connection.js +179 -218
  118. package/server/web-chat/core/conversationContext.js +9 -2
  119. package/server/web-chat/core/directCatalogEngine.js +52 -28
  120. package/server/web-chat/core/dramaKnowledge.js +33 -22
  121. package/server/web-chat/core/followUpEngine.js +735 -81
  122. package/server/web-chat/core/futureEventGuard.js +5 -1
  123. package/server/web-chat/core/musicControlAction.js +70 -0
  124. package/server/web-chat/core/security.js +32 -21
  125. package/server/web-chat/core/toolInspector.js +6 -6
  126. package/server/web-chat/core/urlGuard.js +45 -0
  127. package/server/web-chat/router.js +204 -157
  128. package/server/web-chat/routes/chatRoute.js +885 -488
  129. package/server/web-chat/routes/engineRoute.js +270 -0
  130. package/server/web-chat/routes/exportRoute.js +1 -1
  131. package/server/web-chat/routes/lanDiscoveryRoute.js +17 -2
  132. package/server/web-chat/routes/lmStudioLoadRoute.js +172 -0
  133. package/server/web-chat/routes/mcpRoute.js +386 -0
  134. package/server/web-chat/routes/memoryRoute.js +253 -0
  135. package/server/web-chat/routes/providerRoute.js +49 -16
  136. package/server/web-chat/routes/staticRoute.js +306 -17
  137. package/server/web-chat/routes/statusRoute.js +194 -73
  138. package/server/web-chat/routes/suggestRoute.js +11 -3
  139. package/server/web-chat/routes/verifyRoute.js +26 -4
  140. package/server/web-chat/server.js +7 -6
  141. package/server/web-chat/ui/docsBuilder.js +3558 -0
  142. package/server/web-chat/ui/htmlBuilder.js +12 -0
  143. package/server/web-chat/ui/scripts/client.js +3401 -1302
  144. package/server/web-chat/ui/scripts/webchatI18n.js +155 -153
  145. package/server/web-chat/ui/settingsBuilder.js +7870 -0
  146. package/server/web-chat/ui/styles/lan-card.css +165 -0
  147. package/server/web-chat/ui/styles/main.css +4289 -447
  148. package/server/web-chat/ui/sw.js +64 -0
  149. package/server/web-chat/ui/templates/body.html +407 -281
  150. package/src/data/awardsData.js +146 -146
  151. package/src/data/musicVideoData.js +6 -4
  152. package/src/lib/vram/estimator.js +195 -19
@@ -3,17 +3,20 @@ 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 { resolveDynamicWindowsAppPaths } from './appPaths.js';
6
7
  import http from 'node:http';
7
8
  import https from 'node:https';
8
9
  import { lookup as dnsLookup } from 'node:dns/promises';
9
10
  import { assertSafeUrlHostname, sanitizeUrl, isPrivateIPv4, isPrivateIPv6, isIpAddress } from './sanitizer.js';
10
- import { getContextualizedSearchQuery } from './catalogEntityResolver.js';
11
+ import { getContextualizedSearchQuery, isQueryInDomain, THAI_INTENT_MAP } from './catalogEntityResolver.js';
12
+ import { dedupeNewsItems, rankNewsItems } from './newsIntelligence.js';
11
13
  import { galleryData } from '../../src/data/galleryData.js';
12
14
  import { discographyData } from '../../src/data/discographyData.js';
13
15
  import { musicVideoData } from '../../src/data/musicVideoData.js';
14
16
  import { profileData } from '../../src/data/profileData.js';
15
17
  import { defaultMetaSearch, hybridWebSearch } from './websearch/index.js';
16
18
  import { computeSearchCacheKey, TTL_GENERAL_MS } from './websearch/resilience/cache.js';
19
+ import { appendDomainFilters, planSearchRequest, rerankSearchResults } from './searchQueryPlanner.js';
17
20
  import {
18
21
  decodeHtmlBytes,
19
22
  decodeEntities,
@@ -156,14 +159,17 @@ export async function safeFetch(inputUrl, options = {}, maxRedirects = 5) {
156
159
  let currentOptions = { ...options };
157
160
 
158
161
  for (let redirects = 0; redirects <= maxRedirects; redirects++) {
159
- const sanitized = sanitizeUrl(currentUrl);
162
+ const sanitized = sanitizeUrl(currentUrl, { allowLoopback: Boolean(currentOptions.allowLoopback) });
160
163
  if (!sanitized.safe) throw new Error(`URL rejected by SSRF guard: ${sanitized.reason}`);
161
164
 
162
165
  const parsedUrl = sanitized.url;
163
166
  const hostname = sanitized.hostname;
167
+ const isLoopback = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
164
168
 
165
169
  let targetIp = hostname;
166
- if (!isIpAddress(hostname)) {
170
+ if (currentOptions.allowLoopback && isLoopback) {
171
+ targetIp = hostname;
172
+ } else if (!isIpAddress(hostname)) {
167
173
  const addresses = await safeFetchDnsLookup(hostname);
168
174
  if (!addresses || addresses.length === 0) {
169
175
  throw new Error(`Unable to resolve hostname ${hostname}`);
@@ -603,14 +609,47 @@ async function searchBing(query, maxResults, { language } = {}) {
603
609
  return results;
604
610
  }
605
611
 
612
+ function prepareSearchQuery(rawQuery) {
613
+ let q = String(rawQuery || '').trim();
614
+ if (!q) return '';
615
+ for (const [regex, replacement] of THAI_INTENT_MAP) {
616
+ if (regex.test(q)) {
617
+ q = `${q} ${replacement}`;
618
+ break;
619
+ }
620
+ }
621
+ return q;
622
+ }
623
+
624
+ function buildQueryVariants(rawQuery) {
625
+ const q = String(rawQuery || '').trim();
626
+ const variants = [];
627
+ const noQuotes = q.replace(/["'«»“”„]/g, ' ').replace(/\s+/g, ' ').trim();
628
+ if (noQuotes && noQuotes !== q) variants.push(noQuotes);
629
+
630
+ const cleanStopwords = noQuotes.replace(/\b(what|where|who|is|are|tell me|about|details|news|ขอข้อมูล|เกี่ยวกับ|คือใคร|มีอะไรบ้าง|ไหม|มั้ย)\b/gi, ' ').replace(/\s+/g, ' ').trim();
631
+ if (cleanStopwords && cleanStopwords !== noQuotes && cleanStopwords.length > 2) {
632
+ variants.push(cleanStopwords);
633
+ }
634
+ return variants;
635
+ }
636
+
606
637
  export async function searchWeb(query, maxResults = 8, options = {}) {
607
638
  if (!query || typeof query !== 'string') {
608
639
  return { status: 'error', message: 'Query string is required for web search.' };
609
640
  }
610
641
 
611
- const lang = detectLanguage(query);
642
+ const ownedPlan = !options.queryPlan;
643
+ const queryPlan = options.queryPlan || planSearchRequest({
644
+ keywords: query,
645
+ target_language: options.targetLanguage,
646
+ domain_filter: options.allowedDomains,
647
+ }, { kind: 'web' });
648
+ const originalQuery = options.originalQuery || queryPlan.originalQuery || query;
649
+ const fetchBase = ownedPlan ? (queryPlan.keywords || query) : query;
650
+ const lang = queryPlan.targetLanguage || detectLanguage(fetchBase);
612
651
  const capped = Math.min(Math.max(maxResults, 1), 25);
613
- const cacheKey = computeSearchCacheKey('searchWeb', query, capped);
652
+ const cacheKey = computeSearchCacheKey('searchWeb', `${query}|${queryPlan.domains.join(',')}`, capped);
614
653
 
615
654
  if (!options.noCache) {
616
655
  const cached = defaultMetaSearch.cache.get(cacheKey);
@@ -619,68 +658,91 @@ export async function searchWeb(query, maxResults = 8, options = {}) {
619
658
  }
620
659
  }
621
660
 
622
- try {
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
- ]);
661
+ const preparedQuery = appendDomainFilters(options.skipPrep ? fetchBase : prepareSearchQuery(fetchBase), queryPlan.domains);
631
662
 
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) : [];
663
+ const runAttempt = async (targetQuery) => {
664
+ try {
665
+ const [metaResSettled, imgResSettled] = await Promise.allSettled([
666
+ defaultMetaSearch.executeSearch(targetQuery, {
667
+ limit: capped,
668
+ language: lang,
669
+ ...options
670
+ }),
671
+ searchWebImages(targetQuery, 6, { queryPlan }).catch(() => null)
672
+ ]);
635
673
 
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 => ({
674
+ const metaRes = metaResSettled.status === 'fulfilled' ? metaResSettled.value : null;
675
+ const imgRes = imgResSettled.status === 'fulfilled' ? imgResSettled.value : null;
676
+ const images = Array.isArray(imgRes?.results) ? imgRes.results.slice(0, 6) : [];
677
+
678
+ if (metaRes && metaRes.results && metaRes.results.length > 0) {
679
+ const rankedResults = rerankSearchResults(metaRes.results.map(r => ({
645
680
  title: r.title,
646
681
  url: r.link,
647
682
  snippet: r.snippet,
648
683
  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);
684
+ })), queryPlan, { limit: capped });
685
+ const sources = [...new Set(rankedResults.map(r => r.source || 'web'))];
686
+ const resultObj = {
687
+ status: 'success',
688
+ query: originalQuery,
689
+ normalizedQuery: queryPlan.keywords,
690
+ language: lang,
691
+ count: rankedResults.length,
692
+ sources,
693
+ results: rankedResults,
694
+ ...(images.length > 0 ? { images } : {}),
695
+ ...(metaRes.mode === 'deep_scrape' ? {
696
+ scrapedPages: metaRes.scrapedPages,
697
+ combinedContent: metaRes.combinedContent
698
+ } : {})
699
+ };
700
+ if (!options.noCache) {
701
+ defaultMetaSearch.cache.set(cacheKey, resultObj, TTL_GENERAL_MS);
702
+ }
703
+ return resultObj;
658
704
  }
659
- return resultObj;
660
- }
661
- } catch {}
705
+ } catch {}
662
706
 
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);
707
+ try {
708
+ const wikiItems = await searchWikipedia(targetQuery, Math.min(capped, 3), lang);
709
+ if (wikiItems.length > 0) {
710
+ const wikiResponse = {
711
+ status: 'success',
712
+ query: originalQuery,
713
+ normalizedQuery: queryPlan.keywords,
714
+ language: lang,
715
+ count: wikiItems.length,
716
+ sources: ['Wikipedia'],
717
+ results: wikiItems
718
+ };
719
+ if (!options.noCache) {
720
+ defaultMetaSearch.cache.set(cacheKey, wikiResponse, TTL_GENERAL_MS);
721
+ }
722
+ return wikiResponse;
676
723
  }
677
- return wikiResponse;
678
- }
679
- } catch {}
724
+ } catch {}
725
+ return null;
726
+ };
727
+
728
+ const primaryResult = await runAttempt(preparedQuery);
729
+ if (primaryResult) return primaryResult;
730
+
731
+ if (preparedQuery !== query) {
732
+ const rawResult = await runAttempt(query);
733
+ if (rawResult) return rawResult;
734
+ }
735
+
736
+ const variants = buildQueryVariants(query);
737
+ for (const variant of variants) {
738
+ const variantResult = await runAttempt(variant);
739
+ if (variantResult) return variantResult;
740
+ }
680
741
 
681
742
  return {
682
743
  status: 'no_results',
683
- query,
744
+ query: originalQuery,
745
+ normalizedQuery: queryPlan.keywords,
684
746
  language: lang,
685
747
  count: 0,
686
748
  sources: [],
@@ -691,11 +753,31 @@ export async function searchWeb(query, maxResults = 8, options = {}) {
691
753
  const PAGE_CACHE_TTL_MS = 10 * 60 * 1000;
692
754
  const pageContentCache = new BoundedLruCache(40);
693
755
 
694
- function buildPageResult(pageData, { finalUrl, maxLength, query, cached }) {
756
+ export function buildPageResult(pageData, { finalUrl, maxLength, query, cached }) {
695
757
  const markdown = pageData.markdown || '';
758
+ const totalChars = markdown.length;
759
+
760
+ if (!markdown.trim()) {
761
+ return {
762
+ status: 'error',
763
+ url: finalUrl,
764
+ contentType: 'html',
765
+ contentLength: 0,
766
+ totalPageChars: 0,
767
+ message: 'No readable text could be extracted from this page (login-walled or JavaScript-rendered site such as Weibo, Douyin or Xiaohongshu). Do NOT present this as the final answer — retry in the same turn through an alternate route: (1) the mobile mirror of the URL (e.g. weibo.com → m.weibo.cn keeping the same path), (2) search_chinese_web with the topic keywords, (3) search_news on the topic, or (4) web_search for corroborating pages. Only tell the user the page is unavailable after an alternate route also returns nothing.',
768
+ meta: pageData.meta || {},
769
+ extraction: {
770
+ engine: pageData.engine || 'cheerio-readability',
771
+ mode: 'no_content',
772
+ cached: Boolean(cached),
773
+ charset: pageData.charset || 'utf-8',
774
+ quality: pageData.quality || null
775
+ }
776
+ };
777
+ }
778
+
696
779
  let content = markdown;
697
780
  let contentMode = 'full';
698
- const totalChars = markdown.length;
699
781
 
700
782
  if (query) {
701
783
  const passages = splitIntoPassages(markdown);
@@ -718,7 +800,7 @@ function buildPageResult(pageData, { finalUrl, maxLength, query, cached }) {
718
800
  contentType: 'html',
719
801
  contentLength: content.length,
720
802
  totalPageChars: totalChars,
721
- content: content || 'Unable to retrieve text content from this webpage.',
803
+ content,
722
804
  meta: pageData.meta || {},
723
805
  extraction: {
724
806
  engine: pageData.engine || 'cheerio-readability',
@@ -964,7 +1046,7 @@ export async function fetchJson(url, options = {}) {
964
1046
  } catch {
965
1047
  return { status: 'error', message: 'Invalid URL format.' };
966
1048
  }
967
- const ssrf = await assertSafeUrlHostname(parsedUrl.hostname);
1049
+ const ssrf = await assertSafeUrlHostname(parsedUrl.hostname, { allowLoopback: Boolean(options.allowLoopback) });
968
1050
  if (!ssrf.safe) {
969
1051
  return { status: 'error', message: `URL rejected by SSRF guard: ${ssrf.reason}` };
970
1052
  }
@@ -988,7 +1070,8 @@ export async function fetchJson(url, options = {}) {
988
1070
  'User-Agent': 'KikuAgent/2.0',
989
1071
  ...headers
990
1072
  },
991
- signal: controller.signal
1073
+ signal: controller.signal,
1074
+ allowLoopback: Boolean(options.allowLoopback)
992
1075
  };
993
1076
 
994
1077
  if (body && ['POST', 'PUT', 'PATCH'].includes(fetchOptions.method)) {
@@ -1056,13 +1139,7 @@ export async function fetchJson(url, options = {}) {
1056
1139
  function findChromePath() {
1057
1140
  const p = process.platform;
1058
1141
  if (p === 'win32') {
1059
- const candidates = [
1060
- (process.env.LOCALAPPDATA || '') + '/Google/Chrome/Application/chrome.exe',
1061
- (process.env['PROGRAMFILES'] || '') + '/Google/Chrome/Application/chrome.exe',
1062
- (process.env['PROGRAMFILES(X86)'] || '') + '/Google/Chrome/Application/chrome.exe',
1063
- 'C:/Program Files/Google/Chrome/Application/chrome.exe',
1064
- 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe'
1065
- ];
1142
+ const candidates = resolveDynamicWindowsAppPaths('Google', 'Chrome', 'Application', 'chrome.exe');
1066
1143
  for (const c of candidates) {
1067
1144
  if (c && existsSync(c)) return c;
1068
1145
  }
@@ -1303,69 +1380,88 @@ async function fetchYahooNewsRss(query, maxResults) {
1303
1380
  }
1304
1381
  }
1305
1382
 
1306
- export async function searchNews(query, maxResults = 8) {
1383
+ export async function searchNews(query, maxResults = 8, options = {}) {
1307
1384
  if (!query || typeof query !== 'string') {
1308
1385
  return { status: 'error', message: 'Query string is required for news search.' };
1309
1386
  }
1310
1387
 
1388
+ const ownedPlan = !options.queryPlan;
1389
+ const queryPlan = options.queryPlan || planSearchRequest(query, { kind: 'news' });
1390
+ const searchBase = ownedPlan ? (queryPlan.keywords || query) : query;
1311
1391
  const capped = Math.min(Math.max(maxResults, 1), 20);
1312
- const lang = detectLanguage(query);
1313
- const results = [];
1392
+ const lang = queryPlan.targetLanguage || detectLanguage(searchBase);
1314
1393
  const sources = [];
1315
- const attempts = [];
1316
1394
  const seenUrls = new Set();
1395
+ const rawItems = [];
1317
1396
 
1318
1397
  const add = (item, defaultSource) => {
1319
1398
  if (!item?.url || seenUrls.has(item.url)) return;
1320
1399
  seenUrls.add(item.url);
1321
1400
  const src = item.source || defaultSource;
1322
- results.push({ ...item, source: src });
1401
+ rawItems.push({ ...item, source: src });
1323
1402
  if (!sources.includes(src)) sources.push(src);
1324
1403
  };
1325
1404
 
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 });
1405
+ const isArtistQuery = /(?:จวีจิ้งอี|จิ้งอี|ju\s*jing\s*yi|kiku|鞠婧祎|小鞠)/i.test(`${query} ${searchBase}`);
1406
+ const cleanLocalQuery = searchBase
1407
+ .replace(/(?:ข่าวล่าสุด|ข่าวใหม่|ข่าวสาร|ข่าว|ล่าสุด|เมื่อเร็วๆ\s*นี้|update|news|when:\d+d)/gi, ' ')
1408
+ .replace(/\s+/g, ' ')
1409
+ .trim() || searchBase;
1410
+ const filteredLocalQuery = appendDomainFilters(cleanLocalQuery, queryPlan.domains);
1411
+
1412
+ const cnQuery = isArtistQuery ? getContextualizedSearchQuery(searchBase, 'chinese_web') : null;
1413
+
1414
+ const tasks = [
1415
+ fetchGoogleNewsRss(filteredLocalQuery, capped, lang),
1416
+ fetchBingNewsRss(filteredLocalQuery, capped, lang),
1417
+ fetchYahooNewsRss(filteredLocalQuery, Math.ceil(capped / 2)),
1418
+ searchDuckDuckGo(filteredLocalQuery, Math.min(capped, 4)),
1419
+ searchWebImages(`${filteredLocalQuery} news`, 6, { queryPlan }).catch(() => null)
1420
+ ];
1421
+
1422
+ if (cnQuery && lang !== 'zh') {
1423
+ tasks.push(
1424
+ fetchGoogleNewsRss(cnQuery, capped, 'zh').catch(() => []),
1425
+ fetchBingNewsRss(cnQuery, capped, 'zh').catch(() => [])
1426
+ );
1338
1427
  }
1339
1428
 
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
- }
1429
+ const settled = await Promise.allSettled(tasks);
1430
+
1431
+ const gList = (settled[0]?.status === 'fulfilled' && Array.isArray(settled[0].value)) ? settled[0].value : [];
1432
+ const bList = (settled[1]?.status === 'fulfilled' && Array.isArray(settled[1].value)) ? settled[1].value : [];
1433
+ const yList = (settled[2]?.status === 'fulfilled' && Array.isArray(settled[2].value)) ? settled[2].value : [];
1434
+ const ddgList = (settled[3]?.status === 'fulfilled' && Array.isArray(settled[3].value)) ? settled[3].value : [];
1435
+ const imgRes = settled[4]?.status === 'fulfilled' ? settled[4].value : null;
1436
+ const gCnList = (settled[5]?.status === 'fulfilled' && Array.isArray(settled[5].value)) ? settled[5].value : [];
1437
+ const bCnList = (settled[6]?.status === 'fulfilled' && Array.isArray(settled[6].value)) ? settled[6].value : [];
1345
1438
 
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
1439
  const newsImages = Array.isArray(imgRes?.results) ? imgRes.results.slice(0, 6) : [];
1352
1440
 
1353
1441
  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');
1442
+ const maxLen = Math.max(gList.length, bList.length, yList.length, ddgList.length, gCnList.length, bCnList.length);
1443
+ while (i < maxLen) {
1444
+ if (i < gList.length) add(gList[i], 'Google News');
1445
+ if (i < bList.length) add(bList[i], 'Bing News');
1446
+ if (i < gCnList.length) add(gCnList[i], 'Google News (CN)');
1447
+ if (i < bCnList.length) add(bCnList[i], 'Bing News (CN)');
1448
+ if (i < yList.length) add(yList[i], 'Yahoo News');
1449
+ if (i < ddgList.length) add({ ...ddgList[i], url: ddgList[i].url || ddgList[i].link }, 'Web News');
1359
1450
  i++;
1360
1451
  }
1361
1452
 
1453
+ const deduped = dedupeNewsItems(rawItems);
1454
+ const ranked = rankNewsItems(deduped);
1455
+ const finalResults = rerankSearchResults(ranked, queryPlan, { limit: capped });
1456
+
1362
1457
  return {
1363
- status: results.length > 0 ? 'success' : 'no_results',
1364
- query,
1458
+ status: finalResults.length > 0 ? 'success' : 'no_results',
1459
+ query: queryPlan.originalQuery || query,
1460
+ normalizedQuery: queryPlan.keywords,
1365
1461
  language: lang,
1366
- count: results.length,
1462
+ count: finalResults.length,
1367
1463
  sources,
1368
- results,
1464
+ results: finalResults,
1369
1465
  ...(newsImages.length > 0 ? { images: newsImages } : {})
1370
1466
  };
1371
1467
  }
@@ -1869,6 +1965,23 @@ export function parseBaiduResults(html, maxResults = 8) {
1869
1965
  results.push({ title, url: match[1], snippet, source: 'Baidu' });
1870
1966
  }
1871
1967
  }
1968
+
1969
+ if (results.length === 0) {
1970
+ const altRegex = /<(?:a[^>]+href="([^"]+)"[^>]*>[\s\S]*?<h3[^>]*>([\s\S]*?)<\/h3>|<h3[^>]*>[\s\S]*?<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>|<div[^>]+class="[^"]*c-title[^"]*"[^>]*>[\s\S]*?<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>)([\s\S]*?)(?=<article|<div[^>]+class="[^"]*c-result|<h3|<\/body>|$)/gi;
1971
+ while ((match = altRegex.exec(html)) !== null && results.length < maxResults) {
1972
+ const url = match[1] || match[3] || match[5];
1973
+ const rawTitle = match[2] || match[4] || match[6];
1974
+ const afterText = match[7] || '';
1975
+ if (!url || !rawTitle) continue;
1976
+ const title = cleanHtml(rawTitle);
1977
+ const snippet = cleanHtml(afterText).replace(/["']?\}[\],\}\s]+.*$/g, '').trim().slice(0, 320);
1978
+ if (title && url && !seenUrls.has(url)) {
1979
+ seenUrls.add(url);
1980
+ results.push({ title, url, snippet, source: 'Baidu' });
1981
+ }
1982
+ }
1983
+ }
1984
+
1872
1985
  return results;
1873
1986
  }
1874
1987
 
@@ -1877,9 +1990,13 @@ export async function searchChineseWeb(query, maxResults = 8, options = {}) {
1877
1990
  return { status: 'error', message: 'Query string is required for Chinese web search.' };
1878
1991
  }
1879
1992
 
1880
- const effectiveQuery = typeof options.queryEnricher === 'function'
1881
- ? options.queryEnricher(query, 'chinese_web')
1882
- : getContextualizedSearchQuery(query, 'chinese_web');
1993
+ const ownedPlan = !options.queryPlan;
1994
+ const queryPlan = options.queryPlan || planSearchRequest({ keywords: query, domain_filter: options.allowedDomains }, { kind: 'chinese' });
1995
+ const fetchBase = ownedPlan ? (queryPlan.keywords || query) : query;
1996
+ const enrichedQuery = typeof options.queryEnricher === 'function'
1997
+ ? options.queryEnricher(fetchBase, 'chinese_web')
1998
+ : getContextualizedSearchQuery(fetchBase, 'chinese_web');
1999
+ const effectiveQuery = appendDomainFilters(enrichedQuery, queryPlan.domains);
1883
2000
 
1884
2001
  const capped = Math.min(Math.max(maxResults, 1), 20);
1885
2002
  const results = [];
@@ -1896,21 +2013,51 @@ export async function searchChineseWeb(query, maxResults = 8, options = {}) {
1896
2013
  'Accept-Language': 'zh-CN,zh;q=0.9',
1897
2014
  'Referer': 'https://www.baidu.com/'
1898
2015
  }
1899
- }, 10000);
2016
+ }, 4500);
1900
2017
  if (res.ok) {
1901
2018
  const html = await readResponseTextLimited(res, 3_000_000);
1902
- if (html.includes('百度安全验证') || html.includes('wappass.baidu.com')) {
2019
+ if (!html.includes('百度安全验证') && !html.includes('wappass.baidu.com')) {
2020
+ const items = parseBaiduResults(html, capped);
2021
+ if (items.length > 0) {
2022
+ attempts.push({ source: 'Baidu', status: 'success', count: items.length, durationMs: Date.now() - t0 });
2023
+ return items;
2024
+ }
2025
+ } else {
1903
2026
  attempts.push({ source: 'Baidu', status: 'blocked', message: 'Baidu anti-bot verification challenge.', durationMs: Date.now() - t0 });
1904
- return [];
1905
2027
  }
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;
2028
+ } else {
2029
+ attempts.push({ source: 'Baidu', status: 'error', message: `HTTP ${res.status}`, durationMs: Date.now() - t0 });
1909
2030
  }
1910
- attempts.push({ source: 'Baidu', status: 'error', message: `HTTP ${res.status}`, durationMs: Date.now() - t0 });
1911
2031
  } catch (err) {
1912
- attempts.push({ source: 'Baidu', status: 'error', message: err.name === 'AbortError' ? 'Baidu timeout (10s)' : err.message, durationMs: Date.now() - t0 });
2032
+ attempts.push({ source: 'Baidu', status: 'error', message: err.name === 'AbortError' ? 'Baidu timeout (4.5s)' : err.message, durationMs: Date.now() - t0 });
2033
+ }
2034
+
2035
+ // Mobile Baidu fallback (less prone to desktop captcha challenges)
2036
+ try {
2037
+ const tMobile = Date.now();
2038
+ const mobileUrl = `https://m.baidu.com/s?word=${encodeURIComponent(effectiveQuery)}`;
2039
+ const resM = await fetchWithTimeout(mobileUrl, {
2040
+ headers: {
2041
+ ...BROWSER_HEADERS,
2042
+ 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
2043
+ 'Accept-Language': 'zh-CN,zh;q=0.9',
2044
+ 'Referer': 'https://m.baidu.com/'
2045
+ }
2046
+ }, 3500);
2047
+ if (resM.ok) {
2048
+ const htmlM = await readResponseTextLimited(resM, 3_000_000);
2049
+ if (!htmlM.includes('百度安全验证') && !htmlM.includes('wappass.baidu.com')) {
2050
+ const mItems = parseBaiduResults(htmlM, capped);
2051
+ if (mItems.length > 0) {
2052
+ attempts.push({ source: 'Baidu (Mobile)', status: 'success', count: mItems.length, durationMs: Date.now() - tMobile });
2053
+ return mItems;
2054
+ }
2055
+ }
2056
+ }
2057
+ } catch (_mErr) {
2058
+ // Graceful mobile fallback failure
1913
2059
  }
2060
+
1914
2061
  return [];
1915
2062
  };
1916
2063
 
@@ -2012,7 +2159,7 @@ export async function searchChineseWeb(query, maxResults = 8, options = {}) {
2012
2159
  const fetchOpenWeb = async () => {
2013
2160
  const t0 = Date.now();
2014
2161
  try {
2015
- const cnDomains = options.site ? [options.site] : ['sina.com.cn', 'sohu.com', 'weibo.com', '163.com', 'qq.com'];
2162
+ const cnDomains = options.site ? [options.site] : (queryPlan.domains.length > 0 ? queryPlan.domains : ['sina.com.cn', 'sohu.com', 'weibo.com', '163.com', 'qq.com']);
2016
2163
  const ddgPromises = cnDomains.slice(0, 3).map(domain =>
2017
2164
  searchDuckDuckGo(`${effectiveQuery} site:${domain}`, 2)
2018
2165
  .then(items => items.map(it => ({ ...it, source: `Web (${domain})` })))
@@ -2078,13 +2225,15 @@ export async function searchChineseWeb(query, maxResults = 8, options = {}) {
2078
2225
  }
2079
2226
  } catch {}
2080
2227
 
2228
+ const rankedResults = rerankSearchResults(results, queryPlan, { limit: capped });
2081
2229
  return {
2082
2230
  status: results.length > 0 ? 'success' : 'no_results',
2083
- query,
2084
- count: results.length,
2231
+ query: queryPlan.originalQuery || query,
2232
+ normalizedQuery: queryPlan.keywords,
2233
+ count: rankedResults.length,
2085
2234
  sources: [...new Set(sources)],
2086
2235
  attempts,
2087
- results,
2236
+ results: rankedResults,
2088
2237
  ...(cnImages.length > 0 ? { images: cnImages } : {})
2089
2238
  };
2090
2239
  }
@@ -2119,20 +2268,76 @@ export function decodeBaiduUrl(url) {
2119
2268
  return res;
2120
2269
  }
2121
2270
 
2122
- export async function searchWebImages(query, maxResults = 8) {
2271
+ const ARTIST_IMAGE_TOKEN_RE = /(鞠婧祎|ju\s?jing\s?yi|jujingyi|จวีจิ้งอี|จิ้งอี|小鞠|kikuu?|蜜橘)/i;
2272
+
2273
+ export function titleMentionsArtist(title) {
2274
+ return ARTIST_IMAGE_TOKEN_RE.test(String(title || '').replace(/<[^>]*>/g, ''));
2275
+ }
2276
+
2277
+ export function isLowValueImageUrl(url) {
2278
+ const raw = String(url || '');
2279
+ if (!raw) return true;
2280
+ if (/^data:/i.test(raw)) return true;
2281
+ if (/\.svg(?:[?#]|$)/i.test(raw)) return true;
2282
+ return /\/(?:logo|icons?|favicon|avatar|emoji|sprite)[^/]*$/i.test(raw);
2283
+ }
2284
+
2285
+ export function imageHostKey(url) {
2286
+ try {
2287
+ const parsed = new URL(url);
2288
+ return parsed.host + parsed.pathname.toLowerCase();
2289
+ } catch {
2290
+ return String(url || '');
2291
+ }
2292
+ }
2293
+
2294
+ export function buildImageSearchQuery(query) {
2295
+ if (!isQueryInDomain(query)) {
2296
+ return String(query || '').trim();
2297
+ }
2298
+ const effective = getContextualizedSearchQuery(query, 'chinese_web');
2299
+ if (ARTIST_IMAGE_TOKEN_RE.test(effective)) return effective;
2300
+ return ('鞠婧祎 ' + effective).trim();
2301
+ }
2302
+
2303
+ export async function searchWebImages(query, maxResults = 8, options = {}) {
2123
2304
  if (!query || typeof query !== 'string') {
2124
2305
  return { status: 'error', message: 'Query string is required for image search.' };
2125
2306
  }
2126
2307
 
2127
- const effectiveQuery = getContextualizedSearchQuery(query, 'chinese_web');
2308
+ const ownedPlan = !options.queryPlan;
2309
+ const queryPlan = options.queryPlan || planSearchRequest(query, { kind: 'image' });
2310
+ const fetchBase = ownedPlan ? (queryPlan.keywords || query) : query;
2311
+ const inDomain = isQueryInDomain(fetchBase);
2312
+ const effectiveQuery = appendDomainFilters(buildImageSearchQuery(fetchBase), queryPlan.domains);
2313
+ const requireArtistMatch = inDomain && ARTIST_IMAGE_TOKEN_RE.test(effectiveQuery);
2128
2314
  const capped = Math.min(Math.max(maxResults, 1), 25);
2129
2315
  const results = [];
2130
2316
  const sources = [];
2131
2317
  const seenUrls = new Set();
2318
+ const seenImageKeys = new Set();
2319
+
2320
+ const matchesDomainFilter = (...urls) => {
2321
+ if (queryPlan.domains.length === 0) return true;
2322
+ return urls.some(candidate => {
2323
+ try {
2324
+ const host = new URL(candidate).hostname.replace(/^www\./, '');
2325
+ return queryPlan.domains.some(domain => host === domain || host.endsWith(`.${domain}`));
2326
+ } catch {
2327
+ return false;
2328
+ }
2329
+ });
2330
+ };
2132
2331
 
2133
- const addImage = (item, defaultSource) => {
2332
+ const addImage = (item, defaultSource, { trusted = false } = {}) => {
2134
2333
  if (!item?.imageUrl || seenUrls.has(item.imageUrl)) return false;
2334
+ if (isLowValueImageUrl(item.imageUrl)) return false;
2335
+ const hostKey = imageHostKey(item.imageUrl);
2336
+ if (seenImageKeys.has(hostKey)) return false;
2337
+ if (item.width && item.width < 200) return false;
2338
+ if (requireArtistMatch && !trusted && !titleMentionsArtist(item.title)) return false;
2135
2339
  seenUrls.add(item.imageUrl);
2340
+ seenImageKeys.add(hostKey);
2136
2341
  const src = item.source || defaultSource;
2137
2342
  let sourcePageUrl = item.sourcePageUrl || '';
2138
2343
  if (sourcePageUrl && (sourcePageUrl.includes('ippr_z2C$q') || sourcePageUrl.includes('AzdH3F'))) {
@@ -2141,19 +2346,23 @@ export async function searchWebImages(query, maxResults = 8) {
2141
2346
  if (sourcePageUrl && !/^https?:\/\//i.test(sourcePageUrl)) {
2142
2347
  sourcePageUrl = '';
2143
2348
  }
2349
+ if (!matchesDomainFilter(sourcePageUrl, item.imageUrl)) return false;
2144
2350
  results.push({ ...item, sourcePageUrl, source: src });
2145
2351
  if (!sources.includes(src)) sources.push(src);
2146
2352
  return true;
2147
2353
  };
2148
2354
 
2149
2355
 
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
- );
2356
+ if (inDomain && Array.isArray(galleryData)) {
2357
+ const matchBases = [...new Set([fetchBase, queryPlan.originalQuery].map(v => String(v || '').toLowerCase()).filter(Boolean))];
2358
+ const localMatches = galleryData.filter(g => {
2359
+ const haystacks = [
2360
+ (g.title && g.title.toLowerCase()) || '',
2361
+ (g.category && g.category.toLowerCase()) || '',
2362
+ ...((g.tags || []).map(t => String(t).toLowerCase())),
2363
+ ];
2364
+ return matchBases.some(base => haystacks.some(hay => hay && (hay.includes(base) || base.split(/\s+/).some(token => token.length > 1 && hay.includes(token)))));
2365
+ });
2157
2366
  for (const g of localMatches.slice(0, 3)) {
2158
2367
  addImage({
2159
2368
  title: g.title || effectiveQuery,
@@ -2163,14 +2372,14 @@ export async function searchWebImages(query, maxResults = 8) {
2163
2372
  width: 1080,
2164
2373
  height: 1440,
2165
2374
  source: 'Official Gallery'
2166
- }, 'Official Gallery');
2375
+ }, 'Official Gallery', { trusted: true });
2167
2376
  }
2168
2377
  }
2169
2378
 
2170
2379
 
2171
- const fetchBaiduImages = async () => {
2380
+ const fetchBaiduImages = async (searchQuery = effectiveQuery) => {
2172
2381
  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`;
2382
+ const baiduImgUrl = `https://image.baidu.com/search/acjson?tn=resultjson_com&logid=${Date.now()}&ipn=rj&ct=201326592&is=&fp=result&word=${encodeURIComponent(searchQuery)}&queryWord=${encodeURIComponent(searchQuery)}&cl=2&lm=-1&ie=utf-8&oe=utf-8&pn=0&rn=${Math.min(capped * 2, 30)}&gsm=1e`;
2174
2383
  const res = await fetchWithTimeout(baiduImgUrl, {
2175
2384
  headers: {
2176
2385
  ...BROWSER_HEADERS,
@@ -2224,9 +2433,9 @@ export async function searchWebImages(query, maxResults = 8) {
2224
2433
  };
2225
2434
 
2226
2435
 
2227
- const fetchBingImages = async () => {
2436
+ const fetchBingImages = async (searchQuery = effectiveQuery) => {
2228
2437
  try {
2229
- const url = `https://www.bing.com/images/async?q=${encodeURIComponent(effectiveQuery)}&mmasync=1`;
2438
+ const url = `https://www.bing.com/images/async?q=${encodeURIComponent(searchQuery)}&mmasync=1`;
2230
2439
  const res = await fetchWithTimeout(url, {
2231
2440
  headers: {
2232
2441
  ...BROWSER_HEADERS,
@@ -2261,9 +2470,9 @@ export async function searchWebImages(query, maxResults = 8) {
2261
2470
  };
2262
2471
 
2263
2472
 
2264
- const fetchBilibiliCovers = async () => {
2473
+ const fetchBilibiliCovers = async (searchQuery = effectiveQuery) => {
2265
2474
  try {
2266
- const url = `https://api.bilibili.com/x/web-interface/search/all/v2?keyword=${encodeURIComponent(effectiveQuery)}`;
2475
+ const url = `https://api.bilibili.com/x/web-interface/search/all/v2?keyword=${encodeURIComponent(searchQuery)}`;
2267
2476
  const res = await fetchWithTimeout(url, {
2268
2477
  headers: {
2269
2478
  ...BROWSER_HEADERS,
@@ -2294,6 +2503,16 @@ export async function searchWebImages(query, maxResults = 8) {
2294
2503
  };
2295
2504
 
2296
2505
 
2506
+ const mergeImageRound = (bList, biList, blList) => {
2507
+ let idx = 0;
2508
+ while (results.length < capped && (idx < bList.length || idx < biList.length || idx < blList.length)) {
2509
+ if (idx < bList.length && results.length < capped) addImage(bList[idx], 'Baidu Images');
2510
+ if (idx < biList.length && results.length < capped) addImage(biList[idx], 'Bing Images');
2511
+ if (idx < blList.length && results.length < capped) addImage(blList[idx], 'Bilibili Covers');
2512
+ idx++;
2513
+ }
2514
+ };
2515
+
2297
2516
  const [baiduImgs, bingImgs, biliImgs] = await Promise.allSettled([
2298
2517
  fetchBaiduImages(),
2299
2518
  fetchBingImages(),
@@ -2303,18 +2522,26 @@ export async function searchWebImages(query, maxResults = 8) {
2303
2522
  const bList = baiduImgs.status === 'fulfilled' && Array.isArray(baiduImgs.value) ? baiduImgs.value : [];
2304
2523
  const biList = bingImgs.status === 'fulfilled' && Array.isArray(bingImgs.value) ? bingImgs.value : [];
2305
2524
  const blList = biliImgs.status === 'fulfilled' && Array.isArray(biliImgs.value) ? biliImgs.value : [];
2525
+ mergeImageRound(bList, biList, blList);
2306
2526
 
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++;
2527
+ if (inDomain && results.length < capped) {
2528
+ const [baidu2, bing2, bili2] = await Promise.allSettled([
2529
+ fetchBaiduImages('鞠婧祎'),
2530
+ fetchBingImages('鞠婧祎'),
2531
+ fetchBilibiliCovers('鞠婧祎')
2532
+ ]);
2533
+ mergeImageRound(
2534
+ baidu2.status === 'fulfilled' && Array.isArray(baidu2.value) ? baidu2.value : [],
2535
+ bing2.status === 'fulfilled' && Array.isArray(bing2.value) ? bing2.value : [],
2536
+ bili2.status === 'fulfilled' && Array.isArray(bili2.value) ? bili2.value : []
2537
+ );
2313
2538
  }
2314
2539
 
2315
2540
  if (results.length < capped) {
2316
2541
  try {
2317
- const webRes = await searchChineseWeb(query, Math.min(capped - results.length, 3));
2542
+ const webRes = inDomain
2543
+ ? await searchChineseWeb(query, Math.min(capped - results.length, 3))
2544
+ : await defaultMetaSearch(query, { limit: Math.min(capped - results.length, 3) });
2318
2545
  const webResults = webRes?.results || [];
2319
2546
  const fetchedPages = await Promise.all(webResults.map(async r => {
2320
2547
  try {
@@ -2343,12 +2570,14 @@ export async function searchWebImages(query, maxResults = 8) {
2343
2570
  } catch {}
2344
2571
  }
2345
2572
 
2573
+ const rankedResults = rerankSearchResults(results, queryPlan, { limit: capped });
2346
2574
  return {
2347
2575
  status: results.length > 0 ? 'success' : 'no_results',
2348
- query,
2349
- count: results.length,
2576
+ query: queryPlan.originalQuery || query,
2577
+ normalizedQuery: queryPlan.keywords,
2578
+ count: rankedResults.length,
2350
2579
  sources: [...new Set(sources)],
2351
- results: results.slice(0, capped)
2580
+ results: rankedResults
2352
2581
  };
2353
2582
  }
2354
2583
 
@@ -2628,6 +2857,21 @@ export async function searchBilibili(keyword, maxResults = 8) {
2628
2857
  };
2629
2858
  }
2630
2859
 
2860
+ const extractTopicTokens = (str) => {
2861
+ return String(str || '')
2862
+ .replace(/(?:鞠婧祎|小鞠|jujingyi|ju\s*jing\s*yi|kiku|จวีจิ้งอี|จิ้งอี)/gi, ' ')
2863
+ .replace(/(?:bilibili|b站|video|clip|คลิป|วิดีโอ|ค้นหา|ดู|หา|บน|ใน|ที่|มีไหม|บ้าง|ของ)/gi, ' ')
2864
+ .trim()
2865
+ .toLowerCase()
2866
+ .split(/[\s,+/|&_-]+/)
2867
+ .filter(t => t.length >= 2 || /^[0-9a-z]$/i.test(t));
2868
+ };
2869
+
2870
+ const topicTokens = Array.from(new Set([
2871
+ ...extractTopicTokens(keyword),
2872
+ ...extractTopicTokens(effectiveKeyword)
2873
+ ]));
2874
+
2631
2875
  const items = (json.data?.result || []).slice(0, capped).map(r => {
2632
2876
  const bvid = r.bvid || '';
2633
2877
  const rawPic = r.pic || '';
@@ -2643,33 +2887,56 @@ export async function searchBilibili(keyword, maxResults = 8) {
2643
2887
  const rawDesc = String(r.description || '');
2644
2888
  const lowerDesc = rawDesc.toLowerCase();
2645
2889
 
2646
-
2647
2890
  const hasArtistInAuthor = lowerAuthor.includes('鞠婧祎工作室') || lowerAuthor.includes('鞠婧祎') || lowerAuthor.includes('studio');
2648
2891
  const hasArtistInTitle = cleanTitle.includes('鞠婧祎') || cleanTitle.includes('小鞠') || lowerTitle.includes('kiku') || lowerTitle.includes('jujingyi');
2649
2892
  const hasArtistInDesc = rawDesc.includes('鞠婧祎') || rawDesc.includes('小鞠');
2650
2893
 
2651
2894
  if (hasArtistInAuthor) {
2652
- rankScore += 100;
2653
- } else if (hasArtistInTitle) {
2654
2895
  rankScore += 80;
2896
+ } else if (hasArtistInTitle) {
2897
+ rankScore += 60;
2655
2898
  } else if (hasArtistInDesc) {
2656
- rankScore += 45;
2899
+ rankScore += 25;
2657
2900
  } else {
2658
-
2659
- rankScore -= 40;
2901
+ rankScore -= 50;
2660
2902
  }
2661
2903
 
2904
+ let topicMatchTitleCount = 0;
2905
+ let topicMatchDescCount = 0;
2906
+ if (topicTokens.length > 0) {
2907
+ for (const token of topicTokens) {
2908
+ if (lowerTitle.includes(token)) topicMatchTitleCount++;
2909
+ if (lowerDesc.includes(token)) topicMatchDescCount++;
2910
+ }
2911
+ if (topicMatchTitleCount > 0) {
2912
+ rankScore += 180 + (topicMatchTitleCount * 30);
2913
+ } else if (topicMatchDescCount > 0) {
2914
+ rankScore += 70 + (topicMatchDescCount * 15);
2915
+ } else {
2916
+ rankScore -= 160;
2917
+ }
2918
+ }
2662
2919
 
2663
- if (lowerAuthor.includes('snh48') || lowerAuthor.includes('腾讯') || lowerAuthor.includes('优酷') || lowerAuthor.includes('爱奇艺') || lowerAuthor.includes('芒果') || lowerAuthor.includes('帕莎') || lowerAuthor.includes('prsr') || lowerAuthor.includes('官方')) {
2664
- rankScore += 60;
2920
+ const hasSnhQuery = topicTokens.some(t => t.includes('snh') || t.includes('塞纳河') || t.includes('总选'));
2921
+ if (hasSnhQuery && (lowerAuthor.includes('snh48') || lowerTitle.includes('snh48'))) {
2922
+ rankScore += 70;
2665
2923
  }
2666
2924
 
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;
2925
+ const hasPrsrQuery = topicTokens.some(t => t.includes('prsr') || t.includes('帕莎') || t.includes('pasha') || t.includes('眼镜'));
2926
+ if (hasPrsrQuery && (lowerAuthor.includes('帕莎') || lowerAuthor.includes('prsr') || lowerTitle.includes('帕莎') || lowerTitle.includes('prsr'))) {
2927
+ rankScore += 80;
2670
2928
  }
2671
2929
 
2672
- if (play > 0) rankScore += Math.min(Math.log10(play) * 10, 50);
2930
+ const isVerifiedMediaOrStudio = lowerAuthor.includes('鞠婧祎工作室') || lowerAuthor.includes('腾讯') || lowerAuthor.includes('优酷') || lowerAuthor.includes('爱奇艺') || lowerAuthor.includes('芒果') || lowerAuthor.includes('官方');
2931
+ if (isVerifiedMediaOrStudio) {
2932
+ rankScore += 30;
2933
+ }
2934
+
2935
+ if (lowerTitle.includes('mv') || cleanTitle.includes('官方') || lowerTitle.includes('official') || cleanTitle.includes('舞台') || cleanTitle.includes('现场') || cleanTitle.includes('代言') || cleanTitle.includes('花絮') || cleanTitle.includes('广告')) {
2936
+ rankScore += 25;
2937
+ }
2938
+
2939
+ if (play > 0) rankScore += Math.min(Math.log10(play) * 8, 30);
2673
2940
 
2674
2941
  return {
2675
2942
  title: cleanTitle,
@@ -2683,19 +2950,31 @@ export async function searchBilibili(keyword, maxResults = 8) {
2683
2950
  embedUrl: bvid ? buildBilibiliEmbedUrl(bvid) : '',
2684
2951
  description: rawDesc.slice(0, 300),
2685
2952
  url: bvid ? `https://www.bilibili.com/video/${bvid}` : '',
2686
- rankScore: Math.round(rankScore * 10) / 10
2953
+ rankScore: Math.round(rankScore * 10) / 10,
2954
+ topicMatchTitleCount,
2955
+ topicMatchDescCount
2687
2956
  };
2688
2957
  }).filter(r => r.title && r.url);
2689
2958
 
2690
- items.sort((a, b) => (b.rankScore || 0) - (a.rankScore || 0));
2959
+ const hasTopicSpecificTokens = topicTokens.length > 0;
2960
+ const hasAnyTopicMatch = items.some(item => item.topicMatchTitleCount > 0 || item.topicMatchDescCount > 0);
2961
+
2962
+ const finalItems = items.filter(r => {
2963
+ if (hasTopicSpecificTokens && hasAnyTopicMatch) {
2964
+ return (r.topicMatchTitleCount > 0 || r.topicMatchDescCount > 0) && r.rankScore > 0;
2965
+ }
2966
+ return r.rankScore > -50;
2967
+ });
2968
+
2969
+ finalItems.sort((a, b) => (b.rankScore || 0) - (a.rankScore || 0));
2691
2970
 
2692
2971
  return {
2693
- status: items.length > 0 ? 'success' : 'no_results',
2972
+ status: finalItems.length > 0 ? 'success' : 'no_results',
2694
2973
  keyword,
2695
2974
  searchQuery: effectiveKeyword,
2696
- count: items.length,
2975
+ count: finalItems.length,
2697
2976
  source: 'Bilibili',
2698
- results: items
2977
+ results: finalItems
2699
2978
  };
2700
2979
  } catch (err) {
2701
2980
  return {