kiku-agent 1.3.0 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/package.json +4 -7
- package/server/agent/agentCore.js +2 -2
- package/server/agent/catalogEntityResolver.js +8 -0
- package/server/agent/cli.js +4 -0
- package/server/agent/eval/AGENTS.md +228 -0
- package/server/agent/eval/analyze.py +684 -0
- package/server/agent/eval/gates.json +15 -0
- package/server/agent/eval/modules/analysis/compare.js +198 -0
- package/server/agent/eval/modules/analysis/gates.js +72 -0
- package/server/agent/eval/modules/analysis/judge.js +70 -0
- package/server/agent/eval/modules/core/cli.js +453 -0
- package/server/agent/eval/modules/core/config.js +60 -0
- package/server/agent/eval/modules/core/store.js +236 -0
- package/server/agent/eval/modules/evaluation/cases.js +689 -0
- package/server/agent/eval/modules/evaluation/runner.js +298 -0
- package/server/agent/eval/modules/evaluation/scoring.js +351 -0
- package/server/agent/eval/modules/evaluation/worker.js +122 -0
- package/server/agent/eval/modules/reporting/benchmark.js +265 -0
- package/server/agent/eval/modules/reporting/benchmarkPng.js +487 -0
- package/server/agent/eval/modules/reporting/dashboard.js +1406 -0
- package/server/agent/eval/modules/server/evalApi.js +1099 -0
- package/server/agent/eval/modules/ui/evalUi.js +4796 -0
- package/server/agent/eval/modules/utils/smokes.js +119 -0
- package/server/agent/ink/App.jsx +348 -0
- package/server/agent/ink/Panel.jsx +54 -0
- package/server/agent/ink/PromptInput.jsx +321 -0
- package/server/agent/ink/TaskPlanPanel.jsx +88 -0
- package/server/agent/ink/ThinkingPanel.jsx +68 -0
- package/server/agent/ink/ToolResult.jsx +133 -0
- package/server/agent/ink/main.jsx +195 -0
- package/server/agent/ink/useEventBus.js +218 -0
- package/server/agent/memory/contextAllocator.js +214 -0
- package/server/agent/memory/hierarchicalStore.js +523 -0
- package/server/agent/memory/reflectionWorker.js +110 -0
- package/server/agent/prompts.js +1 -1
- package/server/agent/telemetry/hardwareTiers.js +332 -0
- package/server/agent/telemetry/runtimeWatchdog.js +66 -0
- package/server/agent/telemetry/techPowerUpClient.js +682 -0
- package/server/agent/telemetry/techpowerup-mcp.json +22 -0
- package/server/agent/tests/agentBubbleSystem.test.js +65 -0
- package/server/agent/tests/agentFollowUps.test.js +127 -0
- package/server/agent/tests/apiRouter.test.js +108 -0
- package/server/agent/tests/architectureFixes.test.js +81 -0
- package/server/agent/tests/architectureRefactoring.test.js +194 -0
- package/server/agent/tests/autoResearch.test.js +64 -0
- package/server/agent/tests/bilibiliEmbed.test.js +251 -0
- package/server/agent/tests/catalogSync.test.js +130 -0
- package/server/agent/tests/citations.test.js +180 -0
- package/server/agent/tests/clarificationAnalytics.test.js +219 -0
- package/server/agent/tests/clarifyingQuestions.test.js +594 -0
- package/server/agent/tests/cliAutomation.test.js +121 -0
- package/server/agent/tests/cloudProviders.test.js +234 -0
- package/server/agent/tests/commandInput.test.js +24 -0
- package/server/agent/tests/commands.test.js +417 -0
- package/server/agent/tests/contextIntelligence.test.js +162 -0
- package/server/agent/tests/conversationSafety.test.js +53 -0
- package/server/agent/tests/decisionAndSearchEnhancements.test.js +135 -0
- package/server/agent/tests/dramaAnswerQuality.test.js +73 -0
- package/server/agent/tests/dynamicCatalogIngestion.test.js +285 -0
- package/server/agent/tests/dynamicRoutingAndSearch.test.js +92 -0
- package/server/agent/tests/edgeCasesScenarios.test.js +49 -0
- package/server/agent/tests/entertainmentMetrics.test.js +126 -0
- package/server/agent/tests/envBootstrap.test.js +87 -0
- package/server/agent/tests/envCommand.test.js +215 -0
- package/server/agent/tests/errorHandling.test.js +80 -0
- package/server/agent/tests/errors.test.js +13 -0
- package/server/agent/tests/evalAnalyze.test.js +54 -0
- package/server/agent/tests/evalCommand.test.js +54 -0
- package/server/agent/tests/evalCorpus.test.js +126 -0
- package/server/agent/tests/evalHarness.test.js +62 -0
- package/server/agent/tests/evalJudgeContext.test.js +38 -0
- package/server/agent/tests/evalMetrics.test.js +312 -0
- package/server/agent/tests/evalStore.test.js +190 -0
- package/server/agent/tests/evalStudio.test.js +179 -0
- package/server/agent/tests/feedbackLearning.test.js +186 -0
- package/server/agent/tests/firstRun.test.js +44 -0
- package/server/agent/tests/gemma4eOptimization.test.js +95 -0
- package/server/agent/tests/hardwareTiers.test.js +206 -0
- package/server/agent/tests/hybridWebSearch.test.js +192 -0
- package/server/agent/tests/judgeService.test.js +251 -0
- package/server/agent/tests/lanDiscovery.test.js +319 -0
- package/server/agent/tests/mcpClient.test.js +201 -0
- package/server/agent/tests/modelManager.test.js +109 -0
- package/server/agent/tests/multilingualScenarios.test.js +69 -0
- package/server/agent/tests/newCommands.test.js +347 -0
- package/server/agent/tests/newCommandsTui.test.js +141 -0
- package/server/agent/tests/newTools.test.js +113 -0
- package/server/agent/tests/newsIntelligence.test.js +178 -0
- package/server/agent/tests/nextGenArchitecture.test.js +99 -0
- package/server/agent/tests/offlineInput.test.js +217 -0
- package/server/agent/tests/packagingGuard.test.js +57 -0
- package/server/agent/tests/presentation.test.js +61 -0
- package/server/agent/tests/providerPersistence.test.js +39 -0
- package/server/agent/tests/queryBenchmarks.test.js +83 -0
- package/server/agent/tests/queryDetails.test.js +126 -0
- package/server/agent/tests/realWorldValidation.test.js +391 -0
- package/server/agent/tests/resilienceAndConcurrency.test.js +117 -0
- package/server/agent/tests/runtimeWatchdog.test.js +32 -0
- package/server/agent/tests/sanitizer.test.js +37 -0
- package/server/agent/tests/scenariosData.js +378 -0
- package/server/agent/tests/schemaRouting.test.js +54 -0
- package/server/agent/tests/securityAuditRemediation.test.js +214 -0
- package/server/agent/tests/securityHardening.test.js +146 -0
- package/server/agent/tests/securitySanitizer.test.js +38 -0
- package/server/agent/tests/sessionAndCopy.test.js +232 -0
- package/server/agent/tests/sidebarControls.test.js +101 -0
- package/server/agent/tests/statefulMemory.test.js +270 -0
- package/server/agent/tests/subagent.test.js +216 -0
- package/server/agent/tests/subagentProgress.test.js +109 -0
- package/server/agent/tests/subagentTextFallback.test.js +115 -0
- package/server/agent/tests/suggestions.test.js +199 -0
- package/server/agent/tests/suggestionsTui.test.js +251 -0
- package/server/agent/tests/telemetryAndProfiles.test.js +179 -0
- package/server/agent/tests/terminalImage.test.js +80 -0
- package/server/agent/tests/terminalMarkdown.test.js +166 -0
- package/server/agent/tests/terminalText.test.js +133 -0
- package/server/agent/tests/textProtocolLoop.test.js +126 -0
- package/server/agent/tests/textToolParsing.test.js +139 -0
- package/server/agent/tests/theme.test.js +26 -0
- package/server/agent/tests/toolRegistryParity.test.js +53 -0
- package/server/agent/tests/toolRoutingRegressions.test.js +119 -0
- package/server/agent/tests/tools.test.js +259 -0
- package/server/agent/tests/unifiedEndpoints.test.js +152 -0
- package/server/agent/tests/v110Features.test.js +262 -0
- package/server/agent/tests/versionBump.test.js +69 -0
- package/server/agent/tests/vramEstimator.test.js +520 -0
- package/server/agent/tests/webChatInspection.test.js +300 -0
- package/server/agent/tests/webChatMemory.test.js +112 -0
- package/server/agent/tests/webChatSecurity.test.js +169 -0
- package/server/agent/tests/webExtract.test.js +261 -0
- package/server/agent/tests/webSources.test.js +247 -0
- package/server/agent/websearch/adapters/duckduckgo.js +97 -0
- package/server/agent/websearch/adapters/jina.js +77 -0
- package/server/agent/websearch/adapters/searxng.js +57 -0
- package/server/agent/websearch/core/canonical.js +89 -0
- package/server/agent/websearch/core/fusion.js +81 -0
- package/server/agent/websearch/core/types.js +29 -0
- package/server/agent/websearch/extract.js +605 -0
- package/server/agent/websearch/index.js +43 -0
- package/server/agent/websearch/orchestrator.js +266 -0
- package/server/agent/websearch/resilience/breaker.js +97 -0
- package/server/agent/websearch/resilience/cache.js +59 -0
- package/server/agent/websearch/resilience/limiter.js +93 -0
- package/server/agent/websearch/security/filter.js +36 -0
- package/server/agent/websearch/security/ssrf.js +289 -0
- package/server/agent/websearch/telemetry.js +71 -0
- package/server/apiRouter.js +7 -11
- package/server/eval/judgeRubrics.js +110 -0
- package/server/eval/judgeService.js +290 -0
- package/server/eval/runBenchmark.js +182 -0
- package/server/mcp/examples/librechat.example.yaml +16 -0
- package/server/mcp/tests/connection.test.js +156 -0
- package/server/routes/agent.js +55 -0
- package/server/supabase_schema.sql +63 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
decodeHtmlBytes,
|
|
5
|
+
normalizeCharsetLabel,
|
|
6
|
+
decodeEntities,
|
|
7
|
+
normalizeText,
|
|
8
|
+
extractMainContent,
|
|
9
|
+
splitIntoPassages,
|
|
10
|
+
scorePassages,
|
|
11
|
+
selectPassages,
|
|
12
|
+
filterUniquePassages,
|
|
13
|
+
dedupePassages,
|
|
14
|
+
simhashText,
|
|
15
|
+
hamming64,
|
|
16
|
+
tokenizeForMatch,
|
|
17
|
+
truncateByWords,
|
|
18
|
+
} from '../websearch/extract.js';
|
|
19
|
+
|
|
20
|
+
const ARTICLE_FIXTURE = `<!DOCTYPE html>
|
|
21
|
+
<html lang="th">
|
|
22
|
+
<head>
|
|
23
|
+
<title>Ju Jingyi releases new single</title>
|
|
24
|
+
<meta name="description" content="Coverage of the new single release">
|
|
25
|
+
<link rel="canonical" href="https://example.com/news/ju-jingyi-single">
|
|
26
|
+
<script type="application/ld+json">
|
|
27
|
+
{
|
|
28
|
+
"@type": "NewsArticle",
|
|
29
|
+
"headline": "Ju Jingyi releases new single",
|
|
30
|
+
"datePublished": "2026-09-01",
|
|
31
|
+
"author": {"name": "News Desk"},
|
|
32
|
+
"articleBody": "The JSON-LD body of the article."
|
|
33
|
+
}
|
|
34
|
+
</script>
|
|
35
|
+
</head>
|
|
36
|
+
<body>
|
|
37
|
+
<nav class="main-nav"><a href="/home">Home</a><a href="/news">News</a> Navigation bar text</nav>
|
|
38
|
+
<aside class="sidebar">Sidebar promo banner content here</aside>
|
|
39
|
+
<div class="related-stories">Related recommended stories widget</div>
|
|
40
|
+
<article class="post-content">
|
|
41
|
+
<h1>Ju Jingyi releases new single</h1>
|
|
42
|
+
<p>Ju Jingyi released her newest single on September 1st, alongside a full music video filmed in Chongqing. The song debuted at the top of several regional charts within two days.</p>
|
|
43
|
+
<p>Fans praised the choreography and the cinematic production quality of the accompanying video release.</p>
|
|
44
|
+
<h3>Chart performance</h3>
|
|
45
|
+
<p>The single topped three regional charts and stayed in the top ten for two consecutive weeks across multiple platforms.</p>
|
|
46
|
+
<table>
|
|
47
|
+
<tr><th>Year</th><th>Release</th><th>Label</th></tr>
|
|
48
|
+
<tr><td>2017</td><td>Don't Fall in Love</td><td>STAR48</td></tr>
|
|
49
|
+
<tr><td>2024</td><td>0.2s</td><td>Independent</td></tr>
|
|
50
|
+
</table>
|
|
51
|
+
</article>
|
|
52
|
+
<footer class="site-footer">Copyright notice and legal disclaimer footer</footer>
|
|
53
|
+
</body>
|
|
54
|
+
</html>`;
|
|
55
|
+
|
|
56
|
+
test('decodeHtmlBytes resolves charset from the Content-Type header', () => {
|
|
57
|
+
const thaiBytes = Uint8Array.from([0xA1, 0xA2, 0xA3]);
|
|
58
|
+
const decoded = decodeHtmlBytes(thaiBytes, 'text/html; charset=windows-874');
|
|
59
|
+
assert.equal(decoded.charset, 'windows-874');
|
|
60
|
+
assert.equal(decoded.text, 'กขฃ');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('decodeHtmlBytes sniffs legacy meta charset when the header is silent', () => {
|
|
64
|
+
const html = Buffer.from(
|
|
65
|
+
'<html><head><meta charset="windows-874"></head><body>\xA1\xA2\xA3</body></html>',
|
|
66
|
+
'latin1'
|
|
67
|
+
);
|
|
68
|
+
const decoded = decodeHtmlBytes(html, 'text/html');
|
|
69
|
+
assert.equal(decoded.charset, 'windows-874');
|
|
70
|
+
assert.equal(decoded.text.includes('กขฃ'), true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('decodeHtmlBytes decodes GBK content', () => {
|
|
74
|
+
const gbkBytes = Uint8Array.from([0xD6, 0xD0]);
|
|
75
|
+
const decoded = decodeHtmlBytes(gbkBytes, 'text/html; charset=gbk');
|
|
76
|
+
assert.equal(decoded.text, '中');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('decodeHtmlBytes falls back to utf-8 for unknown or missing charsets', () => {
|
|
80
|
+
const utf8 = Buffer.from('กขค', 'utf8');
|
|
81
|
+
const missing = decodeHtmlBytes(utf8, 'text/html');
|
|
82
|
+
assert.equal(missing.charset, 'utf-8');
|
|
83
|
+
assert.equal(missing.text, 'กขค');
|
|
84
|
+
const unknown = decodeHtmlBytes(utf8, 'text/html; charset=made-up-charset');
|
|
85
|
+
assert.equal(unknown.text, 'กขค');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('decodeHtmlBytes honors byte order marks over header declarations', () => {
|
|
89
|
+
const bom = Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF]), Buffer.from('กขค', 'utf8')]);
|
|
90
|
+
const decoded = decodeHtmlBytes(bom, 'text/html; charset=windows-874');
|
|
91
|
+
assert.equal(decoded.charset, 'utf-8');
|
|
92
|
+
assert.equal(decoded.text, 'กขค');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('normalizeCharsetLabel maps legacy Thai and Chinese labels', () => {
|
|
96
|
+
assert.equal(normalizeCharsetLabel('TIS-620'), 'windows-874');
|
|
97
|
+
assert.equal(normalizeCharsetLabel('gb2312'), 'gbk');
|
|
98
|
+
assert.equal(normalizeCharsetLabel('ISO-8859-1'), 'windows-1252');
|
|
99
|
+
assert.equal(normalizeCharsetLabel(null), null);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('decodeEntities covers numeric, hex and named entities', () => {
|
|
103
|
+
assert.equal(decodeEntities('’'), '’');
|
|
104
|
+
assert.equal(decodeEntities('''), "'");
|
|
105
|
+
assert.equal(decodeEntities('…'), '...');
|
|
106
|
+
assert.equal(decodeEntities('&<>'), '&<>');
|
|
107
|
+
assert.equal(decodeEntities('&unknownentity;'), '&unknownentity;');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('normalizeText strips zero-width characters and applies NFC', () => {
|
|
111
|
+
assert.equal(normalizeText('a\u200Bb\u200Dc\uFEFFd'), 'abcd');
|
|
112
|
+
assert.equal(normalizeText('ก \u00E1 b'), 'ก á b');
|
|
113
|
+
assert.equal(normalizeText(' spaced\t\ntext '), 'spaced text');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('extractMainContent isolates the article body from boilerplate', async () => {
|
|
117
|
+
const result = await extractMainContent(ARTICLE_FIXTURE, { url: 'https://example.com/news/ju-jingyi-single' });
|
|
118
|
+
|
|
119
|
+
assert.equal(result.ok ?? true, true);
|
|
120
|
+
assert.equal(result.meta.title, 'Ju Jingyi releases new single');
|
|
121
|
+
assert.equal(result.meta.canonical, 'https://example.com/news/ju-jingyi-single');
|
|
122
|
+
|
|
123
|
+
assert.equal(result.markdown.includes('released her newest single'), true);
|
|
124
|
+
assert.equal(result.markdown.includes('Sidebar promo banner'), false);
|
|
125
|
+
assert.equal(result.markdown.includes('Navigation bar text'), false);
|
|
126
|
+
assert.equal(result.markdown.includes('Related recommended stories'), false);
|
|
127
|
+
assert.equal(result.markdown.includes('Copyright notice and legal disclaimer'), false);
|
|
128
|
+
|
|
129
|
+
assert.equal(result.markdown.includes('## Ju Jingyi releases new single'), true);
|
|
130
|
+
assert.equal(result.markdown.includes('### Chart performance'), true);
|
|
131
|
+
|
|
132
|
+
assert.equal(result.markdown.includes('| Year | Release | Label |'), true);
|
|
133
|
+
assert.equal(result.markdown.includes("| 2017 | Don't Fall in Love | STAR48 |"), true);
|
|
134
|
+
assert.equal(result.markdown.includes('| 2024 | 0.2s | Independent |'), true);
|
|
135
|
+
|
|
136
|
+
assert.equal(result.paragraphs.some(p => p.startsWith('Ju Jingyi released her newest single')), true);
|
|
137
|
+
assert.equal(result.quality.isThin, false);
|
|
138
|
+
assert.ok(result.quality.density > 0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('extractMainContent parses JSON-LD article metadata', async () => {
|
|
142
|
+
const result = await extractMainContent(ARTICLE_FIXTURE, { url: 'https://example.com/news/ju-jingyi-single' });
|
|
143
|
+
assert.ok(result.jsonLd);
|
|
144
|
+
assert.equal(result.jsonLd.headline, 'Ju Jingyi releases new single');
|
|
145
|
+
assert.equal(result.jsonLd.datePublished, '2026-09-01');
|
|
146
|
+
assert.equal(result.jsonLd.author, 'News Desk');
|
|
147
|
+
assert.equal(result.jsonLd.body, 'The JSON-LD body of the article.');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('extractMainContent flags thin single-page-app shells', async () => {
|
|
151
|
+
const spaShell = `<!DOCTYPE html>
|
|
152
|
+
<html><head><title>App</title></head>
|
|
153
|
+
<body style="padding: 40px; background: linear-gradient(#ffffff 0%, #f8f8f8 100%); min-height: 100vh; font-family: sans-serif;">
|
|
154
|
+
<div id="root"></div>
|
|
155
|
+
<script src="/static/js/bundle.min.js"></script>
|
|
156
|
+
${'<div class="loading-skeleton placeholder shimmer-block"> </div>'.repeat(400)}
|
|
157
|
+
</body></html>`;
|
|
158
|
+
const result = await extractMainContent(spaShell, { url: 'https://app.example.com/' });
|
|
159
|
+
assert.equal(result.quality.isThin, true);
|
|
160
|
+
assert.ok(result.quality.htmlChars > 15_000);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('splitIntoPassages bounds sections by headings and size', () => {
|
|
164
|
+
const markdown = [
|
|
165
|
+
'## Section one',
|
|
166
|
+
'Body of section one.',
|
|
167
|
+
'## Section two',
|
|
168
|
+
'Body of section two.',
|
|
169
|
+
'## Section three',
|
|
170
|
+
'Body of section three.',
|
|
171
|
+
].join('\n');
|
|
172
|
+
|
|
173
|
+
const passages = splitIntoPassages(markdown, { maxChars: 1600 });
|
|
174
|
+
assert.equal(passages.length, 3);
|
|
175
|
+
assert.equal(passages[0].heading, '## Section one');
|
|
176
|
+
assert.equal(passages[1].text.startsWith('## Section two'), true);
|
|
177
|
+
assert.equal(passages[2].index, 2);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('selectPassages ranks sections by query relevance and preserves document order', () => {
|
|
181
|
+
const markdown = [
|
|
182
|
+
'## Music releases',
|
|
183
|
+
'The discography lists many albums and singles from past years with varied labels and formats.',
|
|
184
|
+
'## Cooking recipes',
|
|
185
|
+
'Boil the noodles for four minutes and mix the sauce gently over low heat before serving.',
|
|
186
|
+
'## Chart performance',
|
|
187
|
+
'The 0.2s single topped three regional charts and stayed in the top ten for two weeks.',
|
|
188
|
+
].join('\n\n');
|
|
189
|
+
|
|
190
|
+
const passages = splitIntoPassages(markdown);
|
|
191
|
+
const selection = selectPassages(passages, '0.2s single chart performance', { budgetChars: 150 });
|
|
192
|
+
|
|
193
|
+
assert.equal(selection.total, 3);
|
|
194
|
+
assert.ok(selection.selected >= 1);
|
|
195
|
+
assert.ok(selection.selected < selection.total, 'tight budget must exclude low-relevance sections');
|
|
196
|
+
assert.equal(selection.text.includes('topped three regional charts'), true);
|
|
197
|
+
assert.equal(selection.text.includes('Boil the noodles'), false);
|
|
198
|
+
const chartPos = selection.text.indexOf('Chart performance');
|
|
199
|
+
const musicPos = selection.text.indexOf('Music releases');
|
|
200
|
+
if (musicPos !== -1 && chartPos !== -1) {
|
|
201
|
+
assert.ok(musicPos < chartPos, 'selected passages must follow document order');
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('scorePassages gives zero to empty queries without crashing', () => {
|
|
206
|
+
const passages = splitIntoPassages('## A\n\nhello world content here');
|
|
207
|
+
const scored = scorePassages(passages, '');
|
|
208
|
+
assert.equal(scored.length, 1);
|
|
209
|
+
assert.equal(scored[0].score, 0);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('tokenizeForMatch segments Thai words and uses CJK bigrams', () => {
|
|
213
|
+
const thaiTokens = tokenizeForMatch('จวีจิ้งอีปล่อยเพลงใหม่', 'th');
|
|
214
|
+
assert.ok(thaiTokens.length > 1, 'Thai text must be segmented into multiple words');
|
|
215
|
+
const zhTokens = tokenizeForMatch('鞠婧祎新专辑', 'zh');
|
|
216
|
+
assert.ok(zhTokens.length >= 3, 'Chinese text must produce character bigrams');
|
|
217
|
+
const latin = tokenizeForMatch('Ju Jingyi chart', 'en');
|
|
218
|
+
assert.deepEqual(latin, ['ju', 'jingyi', 'chart']);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('truncateByWords cuts at word boundaries instead of mid-word', () => {
|
|
222
|
+
const thaiText = 'จวีจิ้งอีปล่อยเพลงใหม่ล่าสุดพร้อมมิวสิกวิดีโอ '.repeat(20);
|
|
223
|
+
const truncated = truncateByWords(thaiText, 60, 'th');
|
|
224
|
+
assert.ok(truncated.length <= 60);
|
|
225
|
+
assert.ok(thaiText.startsWith(truncated));
|
|
226
|
+
|
|
227
|
+
const english = 'word '.repeat(100);
|
|
228
|
+
const truncatedEnglish = truncateByWords(english, 30, 'en');
|
|
229
|
+
assert.ok(truncatedEnglish.length <= 30);
|
|
230
|
+
assert.equal(truncatedEnglish.trimEnd().split(' ').pop(), 'word');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('simhashText detects near-duplicate passages across pages', () => {
|
|
234
|
+
const original = 'Ju Jingyi released her newest single on September 1st alongside a full music video filmed in Chongqing.';
|
|
235
|
+
const paraphrase = 'Ju Jingyi released her newest single on September 1st alongside a full music video filmed in Chongqing!';
|
|
236
|
+
const different = 'The recipe for the soup requires boiling noodles for exactly four minutes before serving them hot.';
|
|
237
|
+
|
|
238
|
+
const state = { hashes: [] };
|
|
239
|
+
const first = filterUniquePassages([{ index: 0, heading: '', text: original }], state);
|
|
240
|
+
const duplicate = filterUniquePassages([{ index: 1, heading: '', text: paraphrase }], state);
|
|
241
|
+
const unique = filterUniquePassages([{ index: 2, heading: '', text: different }], state);
|
|
242
|
+
|
|
243
|
+
assert.equal(first.length, 1);
|
|
244
|
+
assert.equal(duplicate.length, 0, 'near-identical passage must be dropped');
|
|
245
|
+
assert.equal(unique.length, 1);
|
|
246
|
+
|
|
247
|
+
const deduped = dedupePassages([
|
|
248
|
+
{ index: 0, heading: '', text: original },
|
|
249
|
+
{ index: 1, heading: '', text: paraphrase },
|
|
250
|
+
{ index: 2, heading: '', text: different },
|
|
251
|
+
]);
|
|
252
|
+
assert.equal(deduped.length, 2);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('hamming64 and simhashText behave on identical and disjoint input', () => {
|
|
256
|
+
const a = simhashText('identical passage text for hashing purposes here');
|
|
257
|
+
const b = simhashText('identical passage text for hashing purposes here');
|
|
258
|
+
assert.equal(hamming64(a, b), 0);
|
|
259
|
+
const c = simhashText('completely unrelated content about chess strategies and endgame play');
|
|
260
|
+
assert.ok(hamming64(a, c) > 6);
|
|
261
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
parseRssFeed,
|
|
6
|
+
parseBaikePage,
|
|
7
|
+
parseBaiduResults,
|
|
8
|
+
detectLanguage,
|
|
9
|
+
checkUrlLiveness,
|
|
10
|
+
decodeBaiduUrl,
|
|
11
|
+
} from '../webSearch.js';
|
|
12
|
+
import { executeTool, prepareToolArguments, toolHandlers, toolsSchema } from '../tools.js';
|
|
13
|
+
import { dynamicToolRouter as toolRouter } from '../toolRouter.js';
|
|
14
|
+
import { extractCitations } from '../citations.js';
|
|
15
|
+
import { PUBLIC_REMOTE_TOOL_NAMES } from '../publicTools.js';
|
|
16
|
+
import { getMcpToolNames } from '../../mcp/tools.js';
|
|
17
|
+
|
|
18
|
+
const NEW_WEB_TOOLS = [
|
|
19
|
+
'search_news',
|
|
20
|
+
'fetch_rss',
|
|
21
|
+
'search_encyclopedia',
|
|
22
|
+
'search_music_platforms',
|
|
23
|
+
'search_chinese_web',
|
|
24
|
+
'search_bilibili',
|
|
25
|
+
'search_web_images',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
test('new web source tools are registered in the schema, router cluster, and public surfaces', () => {
|
|
29
|
+
const schemaNames = new Set(toolsSchema.map(t => t.function?.name));
|
|
30
|
+
for (const name of NEW_WEB_TOOLS) {
|
|
31
|
+
assert.ok(schemaNames.has(name), `toolsSchema should contain ${name}`);
|
|
32
|
+
assert.ok(toolRouter.toolsByName.has(name), `router should know ${name}`);
|
|
33
|
+
assert.ok(PUBLIC_REMOTE_TOOL_NAMES.has(name), `web-chat public tools should include ${name}`);
|
|
34
|
+
assert.ok(typeof toolHandlers[name] === 'function', `toolHandlers should contain ${name}`);
|
|
35
|
+
}
|
|
36
|
+
const mcpNames = new Set(getMcpToolNames());
|
|
37
|
+
for (const name of ['search_news', 'fetch_rss', 'search_encyclopedia', 'search_music_platforms']) {
|
|
38
|
+
assert.ok(mcpNames.has(name), `MCP should expose ${name} publicly`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('parseRssFeed handles RSS 2.0 items with CDATA and source tags', () => {
|
|
43
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
44
|
+
<rss version="2.0"><channel><title>Feed Title</title>
|
|
45
|
+
<item>
|
|
46
|
+
<title><![CDATA[鞠婧祎新剧开播]]></title>
|
|
47
|
+
<link>https://news.example.com/a</link>
|
|
48
|
+
<pubDate>Mon, 01 Sep 2026 08:00:00 GMT</pubDate>
|
|
49
|
+
<description><![CDATA[<p>โพยละครเรื่องใหม่</p>]]></description>
|
|
50
|
+
<source url="https://ent.example.com">Example Ent</source>
|
|
51
|
+
</item>
|
|
52
|
+
<item>
|
|
53
|
+
<title>Second story</title>
|
|
54
|
+
<link>https://news.example.com/b</link>
|
|
55
|
+
<pubDate>Tue, 02 Sep 2026 09:00:00 GMT</pubDate>
|
|
56
|
+
<description>Plain snippet</description>
|
|
57
|
+
</item>
|
|
58
|
+
</channel></rss>`;
|
|
59
|
+
|
|
60
|
+
const items = parseRssFeed(xml);
|
|
61
|
+
assert.equal(items.length, 2);
|
|
62
|
+
assert.equal(items[0].title, '鞠婧祎新剧开播');
|
|
63
|
+
assert.equal(items[0].url, 'https://news.example.com/a');
|
|
64
|
+
assert.equal(items[0].source, 'Example Ent');
|
|
65
|
+
assert.match(items[0].snippet, /โพยละครเรื่องใหม่/);
|
|
66
|
+
assert.match(items[0].publishedAt, /Sep 2026/);
|
|
67
|
+
assert.equal(items[1].source, '');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('parseRssFeed handles Atom entries with self-closing link tags', () => {
|
|
71
|
+
const xml = `<?xml version="1.0"?>
|
|
72
|
+
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
73
|
+
<title>Atom Feed</title>
|
|
74
|
+
<entry>
|
|
75
|
+
<title> Kiku releases new single </title>
|
|
76
|
+
<link rel="alternate" href="https://blog.example.com/entry-1"/>
|
|
77
|
+
<published>2026-08-30T10:00:00Z</published>
|
|
78
|
+
<summary>Short summary text</summary>
|
|
79
|
+
</entry>
|
|
80
|
+
</feed>`;
|
|
81
|
+
|
|
82
|
+
const items = parseRssFeed(xml);
|
|
83
|
+
assert.equal(items.length, 1);
|
|
84
|
+
assert.equal(items[0].title, 'Kiku releases new single');
|
|
85
|
+
assert.equal(items[0].url, 'https://blog.example.com/entry-1');
|
|
86
|
+
assert.equal(items[0].publishedAt, '2026-08-30T10:00:00Z');
|
|
87
|
+
assert.equal(items[0].snippet, 'Short summary text');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('parseBaikePage extracts title, summary and paragraphs from a Baike-style page', () => {
|
|
91
|
+
const html = `<!DOCTYPE html><html><head>
|
|
92
|
+
<meta name="description" content="鞠婧祎,1994年6月18日出生于四川省遂宁市,中国内地影视女演员、流行乐歌手。">
|
|
93
|
+
</head><body>
|
|
94
|
+
<h1>鞠婧祎</h1>
|
|
95
|
+
<div class="lemma-summary"><div class="para">鞠婧祎1994年6月18日出生于四川省遂宁市。</div>
|
|
96
|
+
<div class="para">2013年以SNH48成员身份正式出道。</div></div>
|
|
97
|
+
<p>short</p>
|
|
98
|
+
</body></html>`;
|
|
99
|
+
|
|
100
|
+
const parsed = parseBaikePage(html);
|
|
101
|
+
assert.equal(parsed.title, '鞠婧祎');
|
|
102
|
+
assert.match(parsed.summary, /1994年6月18日出生于四川省遂宁市/);
|
|
103
|
+
assert.ok(parsed.paragraphs.length >= 2);
|
|
104
|
+
assert.ok(parsed.paragraphs.every(p => p.length > 10));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('parseBaiduResults extracts title, redirect link and snippet', () => {
|
|
108
|
+
const html = `<!DOCTYPE html><html><body>
|
|
109
|
+
<div class="result c-container" srcid="1599">
|
|
110
|
+
<h3 class="c-title t t tts-title"><a href="http://www.baidu.com/link?url=abc123">鞠婧祎 最新消息 - 新浪娱乐</a></h3>
|
|
111
|
+
<span class="content-right_8Zs40">新浪娱乐讯 9月1日,鞠婧祎工作室发布最新声明。</span>
|
|
112
|
+
</div>
|
|
113
|
+
<div class="result c-container">
|
|
114
|
+
<h3 class="c-title"><a href="http://www.baidu.com/link?url=def456">第二條結果</a></h3>
|
|
115
|
+
<span class="content-right_8Zs40">另一段摘要文字,內容超過三十個字符以方便驗證截取邏輯是否正確。</span>
|
|
116
|
+
</div>
|
|
117
|
+
</body></html>`;
|
|
118
|
+
|
|
119
|
+
const results = parseBaiduResults(html, 8);
|
|
120
|
+
assert.equal(results.length, 2);
|
|
121
|
+
assert.equal(results[0].title, '鞠婧祎 最新消息 - 新浪娱乐');
|
|
122
|
+
assert.equal(results[0].url, 'http://www.baidu.com/link?url=abc123');
|
|
123
|
+
assert.match(results[0].snippet, /最新声明/);
|
|
124
|
+
assert.equal(results[0].source, 'Baidu');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('detectLanguage routes Thai, Chinese, and English terms', () => {
|
|
128
|
+
assert.equal(detectLanguage('จวีจิ้งอี ล่าสุด'), 'th');
|
|
129
|
+
assert.equal(detectLanguage('鞠婧祎 最新'), 'zh');
|
|
130
|
+
assert.equal(detectLanguage('Ju Jingyi latest news'), 'en');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('search_news requires a query', async () => {
|
|
134
|
+
const result = await toolHandlers.search_news({});
|
|
135
|
+
assert.equal(result.status, 'error');
|
|
136
|
+
assert.match(result.message, /query parameter is required/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('fetch_rss requires a valid URL', async () => {
|
|
140
|
+
const result = await toolHandlers.fetch_rss({ url: 'not-a-url' });
|
|
141
|
+
assert.equal(result.status, 'error');
|
|
142
|
+
assert.match(result.message, /HTTP|URL is required|Invalid URL/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('search_encyclopedia requires a term and rejects unknown languages', async () => {
|
|
146
|
+
const missing = await toolHandlers.search_encyclopedia({});
|
|
147
|
+
assert.equal(missing.status, 'error');
|
|
148
|
+
|
|
149
|
+
const badLang = await executeTool('search_encyclopedia', { term: '鞠婧祎', language: 'fr' });
|
|
150
|
+
assert.equal(badLang.status, 'error');
|
|
151
|
+
assert.match(badLang.message || JSON.stringify(badLang), /language/i);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('chinese web tools are gated behind KIKU_ENABLE_CN_WEB_TOOLS', async () => {
|
|
155
|
+
const previous = process.env.KIKU_ENABLE_CN_WEB_TOOLS;
|
|
156
|
+
try {
|
|
157
|
+
delete process.env.KIKU_ENABLE_CN_WEB_TOOLS;
|
|
158
|
+
for (const [handler, args] of [
|
|
159
|
+
[toolHandlers.search_chinese_web, { query: '鞠婧祎' }],
|
|
160
|
+
[toolHandlers.search_bilibili, { keyword: '鞠婧祎' }],
|
|
161
|
+
[toolHandlers.search_web_images, { query: '鞠婧祎' }],
|
|
162
|
+
]) {
|
|
163
|
+
const gated = await handler(args);
|
|
164
|
+
assert.equal(gated.status, 'error');
|
|
165
|
+
assert.equal(gated.gated, true);
|
|
166
|
+
assert.match(gated.message, /KIKU_ENABLE_CN_WEB_TOOLS/);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
process.env.KIKU_ENABLE_CN_WEB_TOOLS = 'true';
|
|
170
|
+
const missingQuery = await toolHandlers.search_chinese_web({});
|
|
171
|
+
assert.equal(missingQuery.status, 'error');
|
|
172
|
+
assert.match(missingQuery.message, /query parameter is required/);
|
|
173
|
+
const missingKeyword = await toolHandlers.search_bilibili({});
|
|
174
|
+
assert.equal(missingKeyword.status, 'error');
|
|
175
|
+
assert.match(missingKeyword.message, /keyword parameter is required/);
|
|
176
|
+
const missingImageQuery = await toolHandlers.search_web_images({});
|
|
177
|
+
assert.equal(missingImageQuery.status, 'error');
|
|
178
|
+
assert.match(missingImageQuery.message, /query parameter is required/);
|
|
179
|
+
} finally {
|
|
180
|
+
if (previous === undefined) delete process.env.KIKU_ENABLE_CN_WEB_TOOLS;
|
|
181
|
+
else process.env.KIKU_ENABLE_CN_WEB_TOOLS = previous;
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('executeTool validates the new tools through the standard pipeline', async () => {
|
|
186
|
+
const noArgs = await executeTool('search_music_platforms', {});
|
|
187
|
+
assert.equal(noArgs.status, 'error');
|
|
188
|
+
|
|
189
|
+
const prepared = prepareToolArguments('search_news', { query: 'Ju Jingyi', bogus: 1 });
|
|
190
|
+
assert.equal(prepared.safe, true);
|
|
191
|
+
assert.deepEqual(prepared.args, { query: 'Ju Jingyi' });
|
|
192
|
+
assert.match(prepared.warnings.join(' '), /Unknown argument "bogus"/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('router exposes news and encyclopedia tools on research-flavored queries', () => {
|
|
196
|
+
const routedTh = toolRouter.routeToolsForQuery('จวีจิ้งอีมีข่าวอะไรล่าสุดบ้าง');
|
|
197
|
+
const routedZh = toolRouter.routeToolsForQuery('鞠婧祎 最新新闻动态');
|
|
198
|
+
for (const routed of [routedTh, routedZh]) {
|
|
199
|
+
const names = new Set(routed.map(t => t.function?.name));
|
|
200
|
+
assert.ok(names.has('search_news'), 'search_news should be routed for news queries');
|
|
201
|
+
assert.ok(names.has('search_encyclopedia'), 'search_encyclopedia should be routed for research queries');
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('router exposes bilibili and music platform tools on media-flavored queries', () => {
|
|
206
|
+
const routed = toolRouter.routeToolsForQuery('ฟังเพลงล่าสุดของจวีจิ้งอี และดูคลิป bilibili');
|
|
207
|
+
const names = new Set(routed.map(t => t.function?.name));
|
|
208
|
+
assert.ok(names.has('search_bilibili'));
|
|
209
|
+
assert.ok(names.has('search_music_platforms'));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('extractCitations works with recorded tool results (recordedTools regression)', () => {
|
|
213
|
+
const citations = extractCitations(
|
|
214
|
+
'Ju Jingyi news says a new drama is coming: https://example.com/a',
|
|
215
|
+
[{ name: 'web_search', result: { results: [{ title: 'Ju Jingyi new drama', url: 'https://example.com/a' }] } }]
|
|
216
|
+
);
|
|
217
|
+
assert.equal(citations.length, 1);
|
|
218
|
+
assert.equal(citations[0].id, 1);
|
|
219
|
+
assert.equal(citations[0].type, 'web');
|
|
220
|
+
assert.equal(citations[0].url, 'https://example.com/a');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('checkUrlLiveness validates safe URLs and catches dead or invalid links', async () => {
|
|
224
|
+
const invalid = await checkUrlLiveness('not-a-url');
|
|
225
|
+
assert.equal(invalid.alive, false);
|
|
226
|
+
|
|
227
|
+
const empty = await checkUrlLiveness('');
|
|
228
|
+
assert.equal(empty.alive, false);
|
|
229
|
+
|
|
230
|
+
const doc = await checkUrlLiveness('https://example.com/some/article');
|
|
231
|
+
assert.equal(doc.alive, true);
|
|
232
|
+
|
|
233
|
+
const dead = await checkUrlLiveness('https://this-domain-definitely-does-not-exist-at-all-999.xyz/page', { timeoutMs: 500 });
|
|
234
|
+
assert.equal(dead.alive, false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('decodeBaiduUrl decodes Baidu encrypted objURL and fromURL into valid http urls', () => {
|
|
238
|
+
const encrypted = 'ippr_z2C$qAzdH3FAzdH3Fooo_z&e3Bktstktst_z&e3Bv54AzdH3Fet1j5AzdH3FBV8vv0zm5Ep6';
|
|
239
|
+
const decoded = decodeBaiduUrl(encrypted);
|
|
240
|
+
assert.equal(decoded, 'http://www.bilibili.com/video/BV1cc7z6oEtr');
|
|
241
|
+
|
|
242
|
+
assert.equal(decodeBaiduUrl('https://example.com/pic.jpg'), 'https://example.com/pic.jpg');
|
|
243
|
+
assert.equal(decodeBaiduUrl(''), '');
|
|
244
|
+
assert.equal(decodeBaiduUrl(null), '');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createSearchResult } from '../core/types.js';
|
|
2
|
+
import { safeFetchWithPinning } from '../security/ssrf.js';
|
|
3
|
+
import { loadCheerio, decodeEntities, normalizeText } from '../extract.js';
|
|
4
|
+
|
|
5
|
+
function cleanHtml(raw) {
|
|
6
|
+
if (!raw || typeof raw !== 'string') return '';
|
|
7
|
+
return normalizeText(decodeEntities(raw.replace(/<[^>]+>/g, ' ')));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function parseResultsWithCheerio(html, maxResults) {
|
|
11
|
+
const cheerioLoad = await loadCheerio();
|
|
12
|
+
if (!cheerioLoad) return [];
|
|
13
|
+
const $ = cheerioLoad(html);
|
|
14
|
+
const results = [];
|
|
15
|
+
$('div.result, div.web-result').each((_, el) => {
|
|
16
|
+
if (results.length >= maxResults) return;
|
|
17
|
+
const $el = $(el);
|
|
18
|
+
const anchor = $el.find('a.result__a').first();
|
|
19
|
+
let link = anchor.attr('href') || '';
|
|
20
|
+
if (link.includes('uddg=')) {
|
|
21
|
+
try {
|
|
22
|
+
const decoded = new URL('https://duckduckgo.com' + link).searchParams.get('uddg');
|
|
23
|
+
if (decoded) link = decoded;
|
|
24
|
+
} catch {}
|
|
25
|
+
}
|
|
26
|
+
const title = normalizeText(decodeEntities(anchor.text()));
|
|
27
|
+
const snippet = normalizeText(decodeEntities($el.find('.result__snippet').first().text()));
|
|
28
|
+
if (link && title) results.push({ link, title, snippet });
|
|
29
|
+
});
|
|
30
|
+
return results;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseResultsWithRegex(html, maxResults) {
|
|
34
|
+
const results = [];
|
|
35
|
+
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;
|
|
36
|
+
let match;
|
|
37
|
+
while ((match = resultRegex.exec(html)) !== null && results.length < maxResults) {
|
|
38
|
+
results.push({ link: match[1], title: cleanHtml(match[2]), snippet: cleanHtml(match[3]) });
|
|
39
|
+
}
|
|
40
|
+
return results;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function searchDuckDuckGo(query, options = {}) {
|
|
44
|
+
const maxResults = Number(options.limit) || 10;
|
|
45
|
+
const timeoutMs = Number(options.timeoutMs) || 8000;
|
|
46
|
+
const results = [];
|
|
47
|
+
const seenLinks = new Set();
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const lang = options.language || 'en';
|
|
51
|
+
const kl = lang === 'th' ? 'th-th' : (lang === 'zh' ? 'zh-cn' : 'en-us');
|
|
52
|
+
const searchUrl = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}&kl=${kl}`;
|
|
53
|
+
|
|
54
|
+
const res = await safeFetchWithPinning(searchUrl, {
|
|
55
|
+
headers: {
|
|
56
|
+
'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',
|
|
57
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
58
|
+
'Accept-Language': 'th,en-US;q=0.9,en;q=0.8,zh-CN;q=0.7'
|
|
59
|
+
},
|
|
60
|
+
timeoutMs,
|
|
61
|
+
signal: options.signal
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const html = await res.text();
|
|
69
|
+
const cheerioParsed = await parseResultsWithCheerio(html, maxResults);
|
|
70
|
+
const parsed = cheerioParsed.length > 0 ? cheerioParsed : parseResultsWithRegex(html, maxResults);
|
|
71
|
+
|
|
72
|
+
for (const item of parsed) {
|
|
73
|
+
if (results.length >= maxResults) break;
|
|
74
|
+
let rawLink = item.link;
|
|
75
|
+
if (rawLink.includes('uddg=')) {
|
|
76
|
+
try {
|
|
77
|
+
const decoded = new URL('https://duckduckgo.com' + rawLink).searchParams.get('uddg');
|
|
78
|
+
if (decoded) rawLink = decoded;
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (rawLink && item.title && !seenLinks.has(rawLink)) {
|
|
83
|
+
seenLinks.add(rawLink);
|
|
84
|
+
results.push(createSearchResult({
|
|
85
|
+
link: rawLink,
|
|
86
|
+
title: item.title,
|
|
87
|
+
snippet: item.snippet,
|
|
88
|
+
engine: 'duckduckgo',
|
|
89
|
+
score: 1.0 - (results.length * 0.05)
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return results;
|
|
97
|
+
}
|