mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 08:19:59 +00:00
fix: classify DuckDuckGo non-result HTML as failure
This commit is contained in:
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
# WebSearch Configuration Guide
|
# WebSearch Configuration Guide
|
||||||
|
|
||||||
Last Updated: 2026-03-30
|
Last Updated: 2026-04-04
|
||||||
|
|
||||||
CCS provides automatic web search for third-party profiles that cannot access Anthropic's native WebSearch API.
|
CCS provides automatic web search for third-party profiles that cannot access Anthropic's native WebSearch API.
|
||||||
|
|
||||||
@@ -177,6 +177,7 @@ Queries are fingerprinted (`queryHash`, `queryLength`) instead of logged raw by
|
|||||||
2. Keep DuckDuckGo enabled unless you have a strong reason to disable it
|
2. Keep DuckDuckGo enabled unless you have a strong reason to disable it
|
||||||
3. If using Exa, Tavily, or Brave, verify the matching API key
|
3. If using Exa, Tavily, or Brave, verify the matching API key
|
||||||
4. Run with `CCS_DEBUG=1` for runtime logs, or `CCS_WEBSEARCH_TRACE=1` for correlated launch/MCP/provider traces
|
4. Run with `CCS_DEBUG=1` for runtime logs, or `CCS_WEBSEARCH_TRACE=1` for correlated launch/MCP/provider traces
|
||||||
|
5. If DuckDuckGo returns a non-result HTML error, retry later or enable another provider. CCS now treats that as a provider failure instead of a false empty result.
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ const PROVIDER_CONFIG = {
|
|||||||
|
|
||||||
const ddgLinkRe = /<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
|
const ddgLinkRe = /<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
|
||||||
const ddgSnippetRe = /<a class="result__snippet[^"]*".*?>([\s\S]*?)<\/a>/g;
|
const ddgSnippetRe = /<a class="result__snippet[^"]*".*?>([\s\S]*?)<\/a>/g;
|
||||||
|
const ddgNoResultsRe = /class=['"][^'"]*no-results(?:__message)?[^'"]*['"]/i;
|
||||||
|
const ddgNoResultsHeadingRe = /No results found for/i;
|
||||||
const htmlTagRe = /<[^>]+>/g;
|
const htmlTagRe = /<[^>]+>/g;
|
||||||
|
|
||||||
function debug(message) {
|
function debug(message) {
|
||||||
@@ -427,6 +429,30 @@ function extractDuckDuckGoResults(html, count) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function classifyDuckDuckGoHtml(html, count) {
|
||||||
|
const responseHtml = String(html || '');
|
||||||
|
const results = extractDuckDuckGoResults(responseHtml, count);
|
||||||
|
if (results.length > 0) {
|
||||||
|
return {
|
||||||
|
kind: 'results',
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ddgNoResultsRe.test(responseHtml) || ddgNoResultsHeadingRe.test(responseHtml)) {
|
||||||
|
return {
|
||||||
|
kind: 'no_results',
|
||||||
|
results: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'non_result_html',
|
||||||
|
results: [],
|
||||||
|
error: 'DuckDuckGo returned non-result HTML response (possible anti-bot/challenge page)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function formatStructuredSearchResults(query, providerName, results) {
|
function formatStructuredSearchResults(query, providerName, results) {
|
||||||
const lines = [
|
const lines = [
|
||||||
'CCS local WebSearch evidence',
|
'CCS local WebSearch evidence',
|
||||||
@@ -680,10 +706,18 @@ async function tryDuckDuckGoSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
const results = extractDuckDuckGoResults(html, getResultCount('duckduckgo'));
|
const parsed = classifyDuckDuckGoHtml(html, getResultCount('duckduckgo'));
|
||||||
|
if (parsed.kind === 'non_result_html') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `${parsed.error} (status ${response.status})`,
|
||||||
|
statusCode: response.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
content: formatStructuredSearchResults(query, 'DuckDuckGo', results),
|
content: formatStructuredSearchResults(query, 'DuckDuckGo', parsed.results),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -1229,6 +1263,7 @@ if (require.main === module) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
buildFailureHookOutput,
|
buildFailureHookOutput,
|
||||||
buildSuccessHookOutput,
|
buildSuccessHookOutput,
|
||||||
|
classifyDuckDuckGoHtml,
|
||||||
extractDuckDuckGoResults,
|
extractDuckDuckGoResults,
|
||||||
formatStructuredSearchResults,
|
formatStructuredSearchResults,
|
||||||
getActiveProviders,
|
getActiveProviders,
|
||||||
|
|||||||
@@ -198,6 +198,81 @@ describe('ccs-websearch MCP server', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns an MCP error result when DuckDuckGo responds with non-result HTML', async () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-websearch-mcp-server-'));
|
||||||
|
const preloadPath = join(tempDir, 'mock-fetch.cjs');
|
||||||
|
const html = `
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<form action="/anomaly.js" method="post">
|
||||||
|
<input type="hidden" name="q" value="btc price" />
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`.trim();
|
||||||
|
writeFileSync(
|
||||||
|
preloadPath,
|
||||||
|
`global.fetch = async () => ({ ok: true, status: 202, headers: { get: () => null }, text: async () => ${JSON.stringify(html)} });\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const child = spawn('node', ['-r', preloadPath, serverPath], {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CCS_PROFILE_TYPE: 'settings',
|
||||||
|
CCS_WEBSEARCH_ENABLED: '1',
|
||||||
|
CCS_WEBSEARCH_SKIP: '0',
|
||||||
|
CCS_WEBSEARCH_BRAVE: '0',
|
||||||
|
CCS_WEBSEARCH_DUCKDUCKGO: '1',
|
||||||
|
CCS_WEBSEARCH_EXA: '0',
|
||||||
|
CCS_WEBSEARCH_GEMINI: '0',
|
||||||
|
CCS_WEBSEARCH_GROK: '0',
|
||||||
|
CCS_WEBSEARCH_OPENCODE: '0',
|
||||||
|
CCS_WEBSEARCH_TAVILY: '0',
|
||||||
|
},
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const responsesPromise = collectResponses(child, 2);
|
||||||
|
child.stdin.write(
|
||||||
|
encodeMessage({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'initialize',
|
||||||
|
params: {
|
||||||
|
protocolVersion: '2024-11-05',
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: 'bun-test', version: '1.0.0' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
child.stdin.write(
|
||||||
|
encodeMessage({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 2,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'WebSearch', arguments: { query: 'btc price' } },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const responses = await responsesPromise;
|
||||||
|
const toolCall = responses.find((message) => message.id === 2);
|
||||||
|
|
||||||
|
expect(toolCall?.result).toBeDefined();
|
||||||
|
expect((toolCall?.result as { isError: boolean }).isError).toBe(true);
|
||||||
|
expect(
|
||||||
|
((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text
|
||||||
|
).toContain('DuckDuckGo returned non-result HTML response');
|
||||||
|
expect(
|
||||||
|
((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text
|
||||||
|
).not.toContain('Result count: 0');
|
||||||
|
} finally {
|
||||||
|
child.kill();
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts the legacy search alias for direct calls', async () => {
|
it('accepts the legacy search alias for direct calls', async () => {
|
||||||
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-websearch-mcp-server-'));
|
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-websearch-mcp-server-'));
|
||||||
const preloadPath = join(tempDir, 'mock-fetch.cjs');
|
const preloadPath = join(tempDir, 'mock-fetch.cjs');
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as {
|
|||||||
providerName: string,
|
providerName: string,
|
||||||
content: string
|
content: string
|
||||||
) => HookOutput;
|
) => HookOutput;
|
||||||
|
classifyDuckDuckGoHtml: (
|
||||||
|
html: string,
|
||||||
|
count: number
|
||||||
|
) => {
|
||||||
|
error?: string;
|
||||||
|
kind: 'results' | 'no_results' | 'non_result_html';
|
||||||
|
results: Array<{ title: string; url: string; description: string }>;
|
||||||
|
};
|
||||||
extractDuckDuckGoResults: (html: string, count: number) => Array<{
|
extractDuckDuckGoResults: (html: string, count: number) => Array<{
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -50,17 +58,37 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as {
|
|||||||
parseRetryAfterSeconds: (rawValue: string) => number | null;
|
parseRetryAfterSeconds: (rawValue: string) => number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function runHookWithMockedFetch(mode: 'success' | 'failure') {
|
function runHookWithMockedFetch(mode: 'success' | 'empty' | 'non-result' | 'failure') {
|
||||||
const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-'));
|
const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-'));
|
||||||
const preloadPath = join(tempDir, 'mock-fetch.cjs');
|
const preloadPath = join(tempDir, 'mock-fetch.cjs');
|
||||||
const html = `
|
const successHtml = `
|
||||||
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Example title</a>
|
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Example title</a>
|
||||||
<a class="result__snippet">Example snippet</a>
|
<a class="result__snippet">Example snippet</a>
|
||||||
`.trim();
|
`.trim();
|
||||||
|
const emptyHtml = `
|
||||||
|
<span class="no-results">
|
||||||
|
<div class="no-results__message">
|
||||||
|
<h1>No results found for <strong>btc price</strong></h1>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
`.trim();
|
||||||
|
const nonResultHtml = `
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<form action="/anomaly.js" method="post">
|
||||||
|
<input type="hidden" name="q" value="btc price" />
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`.trim();
|
||||||
const preloadScript =
|
const preloadScript =
|
||||||
mode === 'success'
|
mode === 'success'
|
||||||
? `global.fetch = async () => ({ ok: true, text: async () => ${JSON.stringify(html)} });\n`
|
? `global.fetch = async () => ({ ok: true, status: 200, headers: { get: () => null }, text: async () => ${JSON.stringify(successHtml)} });\n`
|
||||||
: `global.fetch = async () => ({ ok: false, status: 503, text: async () => 'Service unavailable' });\n`;
|
: mode === 'empty'
|
||||||
|
? `global.fetch = async () => ({ ok: true, status: 200, headers: { get: () => null }, text: async () => ${JSON.stringify(emptyHtml)} });\n`
|
||||||
|
: mode === 'non-result'
|
||||||
|
? `global.fetch = async () => ({ ok: true, status: 202, headers: { get: () => null }, text: async () => ${JSON.stringify(nonResultHtml)} });\n`
|
||||||
|
: `global.fetch = async () => ({ ok: false, status: 503, headers: { get: () => null }, text: async () => 'Service unavailable' });\n`;
|
||||||
|
|
||||||
writeFileSync(preloadPath, preloadScript, 'utf8');
|
writeFileSync(preloadPath, preloadScript, 'utf8');
|
||||||
|
|
||||||
@@ -149,6 +177,35 @@ describe('websearch-transformer hook helpers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('distinguishes legitimate DuckDuckGo zero-result pages from unusable HTML', () => {
|
||||||
|
const emptyPage = `
|
||||||
|
<span class="no-results">
|
||||||
|
<div class="no-results__message">
|
||||||
|
<h1>No results found for <strong>btc price</strong></h1>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
const nonResultPage = `
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<form action="/anomaly.js" method="post">
|
||||||
|
<input type="hidden" name="q" value="btc price" />
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(hook.classifyDuckDuckGoHtml(emptyPage, 5)).toEqual({
|
||||||
|
kind: 'no_results',
|
||||||
|
results: [],
|
||||||
|
});
|
||||||
|
expect(hook.classifyDuckDuckGoHtml(nonResultPage, 5)).toEqual({
|
||||||
|
kind: 'non_result_html',
|
||||||
|
results: [],
|
||||||
|
error: 'DuckDuckGo returned non-result HTML response (possible anti-bot/challenge page)',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('formats structured search results for hook deny output', () => {
|
it('formats structured search results for hook deny output', () => {
|
||||||
const formatted = hook.formatStructuredSearchResults('ccs websearch', 'DuckDuckGo', [
|
const formatted = hook.formatStructuredSearchResults('ccs websearch', 'DuckDuckGo', [
|
||||||
{
|
{
|
||||||
@@ -224,6 +281,18 @@ describe('websearch-transformer hook helpers', () => {
|
|||||||
expect(output).not.toHaveProperty('additionalContext');
|
expect(output).not.toHaveProperty('additionalContext');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves genuine DuckDuckGo zero-result pages as successful empty searches', () => {
|
||||||
|
const result = runHookWithMockedFetch('empty');
|
||||||
|
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
expect(result.stderr.trim()).toBe('');
|
||||||
|
|
||||||
|
const output = JSON.parse(result.stdout.trim()) as HookOutput;
|
||||||
|
expect(output.hookSpecificOutput.additionalContext).toContain('Provider: DuckDuckGo');
|
||||||
|
expect(output.hookSpecificOutput.additionalContext).toContain('Result count: 0');
|
||||||
|
expect(output.hookSpecificOutput.additionalContext).toContain('No results found.');
|
||||||
|
});
|
||||||
|
|
||||||
it('emits runtime failure output with attempted provider details nested under hookSpecificOutput', () => {
|
it('emits runtime failure output with attempted provider details nested under hookSpecificOutput', () => {
|
||||||
const result = runHookWithMockedFetch('failure');
|
const result = runHookWithMockedFetch('failure');
|
||||||
|
|
||||||
@@ -244,6 +313,20 @@ describe('websearch-transformer hook helpers', () => {
|
|||||||
expect(output).not.toHaveProperty('additionalContext');
|
expect(output).not.toHaveProperty('additionalContext');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats DuckDuckGo non-result HTML as provider failure instead of fake empty results', () => {
|
||||||
|
const result = runHookWithMockedFetch('non-result');
|
||||||
|
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
expect(result.stderr.trim()).toBe('');
|
||||||
|
|
||||||
|
const output = JSON.parse(result.stdout.trim()) as HookOutput;
|
||||||
|
expect(output.hookSpecificOutput.permissionDecision).toBe('deny');
|
||||||
|
expect(output.hookSpecificOutput.additionalContext).toContain(
|
||||||
|
'Attempted providers: DuckDuckGo: DuckDuckGo returned non-result HTML response'
|
||||||
|
);
|
||||||
|
expect(output.hookSpecificOutput.additionalContext).not.toContain('Result count: 0');
|
||||||
|
});
|
||||||
|
|
||||||
it('writes opt-in trace records with redacted query fingerprints', () => {
|
it('writes opt-in trace records with redacted query fingerprints', () => {
|
||||||
const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-trace-'));
|
const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-trace-'));
|
||||||
const preloadPath = join(tempDir, 'mock-fetch.cjs');
|
const preloadPath = join(tempDir, 'mock-fetch.cjs');
|
||||||
|
|||||||
Reference in New Issue
Block a user