Skip to content

Commit 8852a70

Browse files
committed
feat: harden web search resilience
1 parent bcb4219 commit 8852a70

11 files changed

Lines changed: 791 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## v1.8.0 - 2026-06-26
4+
5+
### Added
6+
7+
- In-process `web-search` result cache with `--no-cache` bypass.
8+
- DuckDuckGo Lite rate-limit detection with typed fallback semantics.
9+
10+
### Improved
11+
12+
- DuckDuckGo Lite rate-limit and anti-bot responses now retry briefly before returning an engine error or allowing auto/provider fallback.
13+
- Search resilience iteration plan for #2/#3, including why Brave/Mojeek are out of scope and Startpage remains a gated follow-up.
14+
315
## v1.7.0 - 2026-06-16
416

517
Feature work for local metrics and GUI observability.

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ web-tools web-search "latest AI news"
144144
web-tools web-search "AI latest developments" --locale en-US --limit 3
145145
web-tools web-search "golang readability" --include-domain github.com --exclude-domain reddit.com
146146
web-tools web-search "golang readability" --provider duckduckgo --json
147+
web-tools web-search "golang readability" --no-cache --json
147148

148149
# Read a URL
149150
web-tools web-reader https://example.com/article
@@ -262,6 +263,10 @@ CLI flags override config defaults when provided. `--format=html` is only availa
262263

263264
`web-reader --json` includes a `quality` object with extraction score, word count, minimum word threshold, fallback recommendation, and reasons. Sparse extraction warnings are written to stderr so stdout remains machine-consumable.
264265

266+
`web-search` keeps a short in-process result cache to avoid duplicate backend requests in GUI and library-style usage. Use `--no-cache` to bypass it. The cache is not persisted to disk and does not store queries in metrics.
267+
268+
DuckDuckGo Lite rate-limit or anti-bot responses are surfaced as structured engine errors and retried briefly before auto mode falls back to the next configured provider. Warnings stay on stderr so JSON stdout remains machine-consumable.
269+
265270
### Provider configuration
266271

267272
`--provider` is the preferred selector for new integrations. `--engine` remains supported for compatibility with `auto`, `duckduckgo`, and `searxng`.

README.zh-CN.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ web-tools web-search "latest AI news"
145145
web-tools web-search "AI latest developments" --locale en-US --limit 3
146146
web-tools web-search "golang readability" --include-domain github.com --exclude-domain reddit.com
147147
web-tools web-search "golang readability" --provider duckduckgo --json
148+
web-tools web-search "golang readability" --no-cache --json
148149

149150
# 读取 URL
150151
web-tools web-reader https://example.com/article
@@ -259,6 +260,10 @@ CLI 参数会覆盖配置默认值。`--format=html` 只在提取结果里真的
259260

260261
`web-reader --json` 包含 `quality` 对象,包括提取评分、词数、最低词数阈值、是否建议 fallback 和原因。内容稀疏 warning 会写到 stderr,stdout 保持机器可读。
261262

263+
`web-search` 会保留短生命周期的进程内结果缓存,减少 GUI 或库调用场景里的重复后端请求。使用 `--no-cache` 可以绕过缓存。缓存不会持久化到磁盘,metrics 也不会记录 query。
264+
265+
DuckDuckGo Lite 的限流或反爬响应会返回结构化 engine error,并在 auto 模式 fallback 到下一个 provider 前做短重试。warning 只写 stderr,JSON stdout 保持机器可读。
266+
262267
### Provider 配置
263268

264269
`--provider` 是新集成的推荐入口。`--engine` 继续保留,用于兼容 `auto``duckduckgo``searxng`

cmd/web-search/main.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ func Cmd() *cobra.Command {
2323
flagLocale string
2424
flagCat string
2525
flagTime string
26+
flagNoCache bool
2627
flagIncludeDomains []string
2728
flagExcludeDomains []string
2829
)
@@ -40,7 +41,7 @@ instance (opt-in, requires Docker). Zero cost, no API keys.`,
4041
web-tools web-search "climate change 2026" --time-range year --json`,
4142
Args: cobra.ExactArgs(1),
4243
Run: func(cmd *cobra.Command, args []string) {
43-
run(cmd, args[0], flagJSON, flagOutput, flagLimit, flagEngine, flagProvider, flagLocale, flagCat, flagTime, flagIncludeDomains, flagExcludeDomains)
44+
run(cmd, args[0], flagJSON, flagOutput, flagLimit, flagEngine, flagProvider, flagLocale, flagCat, flagTime, flagNoCache, flagIncludeDomains, flagExcludeDomains)
4445
},
4546
}
4647

@@ -52,13 +53,14 @@ instance (opt-in, requires Docker). Zero cost, no API keys.`,
5253
cmd.Flags().StringVar(&flagLocale, "locale", "auto", "Language preference (zh-CN, en-US, auto)")
5354
cmd.Flags().StringVar(&flagCat, "category", "general", "Search category: general / images / news / videos / files")
5455
cmd.Flags().StringVar(&flagTime, "time-range", "any", "Time range: any / day / week / month / year")
56+
cmd.Flags().BoolVar(&flagNoCache, "no-cache", false, "Bypass in-process search result cache")
5557
cmd.Flags().StringSliceVar(&flagIncludeDomains, "include-domain", nil, "Only include results from domain(s); repeat or comma-separate")
5658
cmd.Flags().StringSliceVar(&flagExcludeDomains, "exclude-domain", nil, "Exclude results from domain(s); repeat or comma-separate")
5759

5860
return cmd
5961
}
6062

61-
func run(cmd *cobra.Command, query string, flagJSON bool, flagOutput string, flagLimit int, flagEngine string, flagProvider string, flagLocale string, flagCategory string, flagTimeRange string, flagIncludeDomains []string, flagExcludeDomains []string) {
63+
func run(cmd *cobra.Command, query string, flagJSON bool, flagOutput string, flagLimit int, flagEngine string, flagProvider string, flagLocale string, flagCategory string, flagTimeRange string, flagNoCache bool, flagIncludeDomains []string, flagExcludeDomains []string) {
6264
start := time.Now()
6365
var metric metrics.Event
6466
var runErr error
@@ -77,7 +79,7 @@ func run(cmd *cobra.Command, query string, flagJSON bool, flagOutput string, fla
7779
}
7880
s := search.NewSearchWithConfig(*cfg)
7981

80-
opts, err := buildSearchOptions(cmd, flagLimit, flagEngine, flagProvider, flagLocale, flagCategory, flagTimeRange, flagIncludeDomains, flagExcludeDomains)
82+
opts, err := buildSearchOptions(cmd, flagLimit, flagEngine, flagProvider, flagLocale, flagCategory, flagTimeRange, flagNoCache, flagIncludeDomains, flagExcludeDomains)
8183
if err != nil {
8284
runErr = err
8385
apperrors.HandleError(runErr)
@@ -121,7 +123,7 @@ func firstNonEmpty(values ...string) string {
121123
return ""
122124
}
123125

124-
func buildSearchOptions(cmd *cobra.Command, flagLimit int, flagEngine string, flagProvider string, flagLocale string, flagCategory string, flagTimeRange string, flagIncludeDomains []string, flagExcludeDomains []string) (search.SearchOptions, error) {
126+
func buildSearchOptions(cmd *cobra.Command, flagLimit int, flagEngine string, flagProvider string, flagLocale string, flagCategory string, flagTimeRange string, flagNoCache bool, flagIncludeDomains []string, flagExcludeDomains []string) (search.SearchOptions, error) {
125127
var opts search.SearchOptions
126128
engineChanged := cmd.Flags().Changed("engine")
127129
providerChanged := cmd.Flags().Changed("provider")
@@ -150,6 +152,9 @@ func buildSearchOptions(cmd *cobra.Command, flagLimit int, flagEngine string, fl
150152
if cmd.Flags().Changed("time-range") {
151153
opts.TimeRange = flagTimeRange
152154
}
155+
if cmd.Flags().Changed("no-cache") {
156+
opts.NoCache = flagNoCache
157+
}
153158
if cmd.Flags().Changed("include-domain") {
154159
opts.IncludeDomains = normalizeDomainFlags(flagIncludeDomains)
155160
}

cmd/web-search/main_test.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ func TestLoadSearchConfig_UsesEnvOverrideForSearXNGURL(t *testing.T) {
2020
func TestBuildSearchOptions_OmittedFlagsStayZero(t *testing.T) {
2121
cmd := Cmd()
2222

23-
opts, err := buildSearchOptions(cmd, 5, "auto", "auto", "auto", "general", "any", nil, nil)
23+
opts, err := buildSearchOptions(cmd, 5, "auto", "auto", "auto", "general", "any", false, nil, nil)
2424

2525
require.NoError(t, err)
2626
assert.Equal(t, search.SearchOptions{}, opts)
@@ -34,10 +34,11 @@ func TestBuildSearchOptions_ExplicitFlagsOverride(t *testing.T) {
3434
require.NoError(t, cmd.Flags().Set("locale", "en-US"))
3535
require.NoError(t, cmd.Flags().Set("category", "news"))
3636
require.NoError(t, cmd.Flags().Set("time-range", "week"))
37+
require.NoError(t, cmd.Flags().Set("no-cache", "true"))
3738
require.NoError(t, cmd.Flags().Set("include-domain", "example.com,docs.example.com"))
3839
require.NoError(t, cmd.Flags().Set("exclude-domain", "spam.example"))
3940

40-
opts, err := buildSearchOptions(cmd, 2, "searxng", "searxng", "en-US", "news", "week", []string{"example.com", "docs.example.com"}, []string{"spam.example"})
41+
opts, err := buildSearchOptions(cmd, 2, "searxng", "searxng", "en-US", "news", "week", true, []string{"example.com", "docs.example.com"}, []string{"spam.example"})
4142

4243
require.NoError(t, err)
4344
assert.Equal(t, 2, opts.Limit)
@@ -46,6 +47,7 @@ func TestBuildSearchOptions_ExplicitFlagsOverride(t *testing.T) {
4647
assert.Equal(t, "en-US", opts.Locale)
4748
assert.Equal(t, "news", opts.Category)
4849
assert.Equal(t, "week", opts.TimeRange)
50+
assert.True(t, opts.NoCache)
4951
assert.Equal(t, []string{"example.com", "docs.example.com"}, opts.IncludeDomains)
5052
assert.Equal(t, []string{"spam.example"}, opts.ExcludeDomains)
5153
}
@@ -54,7 +56,7 @@ func TestBuildSearchOptions_ProviderWithoutEngine(t *testing.T) {
5456
cmd := Cmd()
5557
require.NoError(t, cmd.Flags().Set("provider", "duckduckgo"))
5658

57-
opts, err := buildSearchOptions(cmd, 5, "auto", "duckduckgo", "auto", "general", "any", nil, nil)
59+
opts, err := buildSearchOptions(cmd, 5, "auto", "duckduckgo", "auto", "general", "any", false, nil, nil)
5860

5961
require.NoError(t, err)
6062
assert.Equal(t, "duckduckgo", opts.Provider)
@@ -66,7 +68,7 @@ func TestBuildSearchOptions_ConflictingEngineAndProvider(t *testing.T) {
6668
require.NoError(t, cmd.Flags().Set("engine", "searxng"))
6769
require.NoError(t, cmd.Flags().Set("provider", "duckduckgo"))
6870

69-
_, err := buildSearchOptions(cmd, 5, "searxng", "duckduckgo", "auto", "general", "any", nil, nil)
71+
_, err := buildSearchOptions(cmd, 5, "searxng", "duckduckgo", "auto", "general", "any", false, nil, nil)
7072

7173
require.Error(t, err)
7274
assert.Contains(t, err.Error(), "conflicting provider flags")

0 commit comments

Comments
 (0)