Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,10 @@ schedule_delay: 10m

服务实际观察到 08:00 / 18:00 触发时,会把该窗口登记到唯一的长期状态文件 `output/state/briefing-scheduler.json`。只有处于 `pending`、`waiting_x` 或 `running` 的已登记窗口才有 watcher;默认每 1 分钟检查一次,进入 `done` / `failed` 后立即停止。若同窗口的 X 状态仍为 `running` 且 heartbeat 新鲜,窗口切到 `waiting_x`;X 进入终态或 heartbeat 超过 3 分钟未更新时,watcher 接管执行。简报自身也每分钟更新 heartbeat,超过 3 分钟可由重启后的 watcher 接管。cron、X 回调和 watcher 通过短期文件锁原子竞争同一窗口的 lease,所以同一窗口只会有一个有效执行者;旧 lease 不能覆盖接管后的状态。邮件成功时间也保存在同一记录中,接管时不会重复发送已经确认成功的邮件。短期 `.lock` 和原子写临时文件只在更新状态时存在,不是长期 marker。

Watch 默认走“索引快检 + 正文深检”:索引新增或变化会立即读取正文;未变化文章按 `watch.deep_verify_interval` 到期后,以 `watch.deep_verify_batch_size` 为上限按最旧检查时间轮转。这样仍能发现 URL、标题和摘要均未变化时的正文静默更新,同时避免每个简报窗口下载全部历史正文。

RSS 源会在 `<output.dir>/state/rss-cache` 保存压缩响应和 ETag/Last-Modified 元数据。服务端返回 `304 Not Modified` 时复用已缓存 Feed;每份 `.source-stats.json` 同时记录各来源的抓取耗时、响应字节数与缓存状态,便于识别大 Feed 和低有效率来源。

注意:`serve` 启动时只恢复上述状态文件里尚未结束的窗口,不会推算或补跑服务完全错过的历史触发点。例如 07:50 停服、08:01 启动时不会自动创建 08:00 窗口,按需使用 `regen` 手动补跑。

建议:调整 cron / `schedule` 后,如怀疑有断层,优先使用项目自带的 `regen --from --to` 手动补窗,例如:
Expand Down
1 change: 1 addition & 0 deletions cmds/news-briefing/execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ type emailDeps struct {
func newApp(cfg *config.Config) *app {
httpClient := fetcher.NewHTTPClient(cfg.Proxy, cfg.Fetch.Timeout)
fetchClient := fetcher.NewClient(httpClient)
fetchClient.SetRSSCacheDir(filepath.Join(cfg.Output.Dir, "state", "rss-cache"))
watchRunner := watch.NewRunner(httpClient)
aiRunner := summarizer.NewRunnerWithRetryDelays(cfg.AI.Command, cfg.AI.Args, cfg.AI.ShouldAppendSystemPrompt(), cfg.Proxy.HTTP, cfg.Proxy.Socks5, cfg.AI.Retry.Delays)
aiRunner.SetModels(cfg.AI.Models.Default, cfg.AI.Models.Translation)
Expand Down
16 changes: 14 additions & 2 deletions cmds/news-briefing/scheduled_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type scheduledStateFile struct {
}

type scheduledWindowState struct {
RunID string `json:"runId,omitempty"`
Expr string `json:"expr"`
Period string `json:"period"`
From time.Time `json:"from"`
Expand Down Expand Up @@ -162,6 +163,10 @@ func scheduledRunWindowKey(window scheduler.Window) string {
return window.From.UTC().Format("20060102T150405Z") + "_" + window.To.UTC().Format("20060102T150405Z")
}

func scheduledRunID(window scheduler.Window) string {
return scheduledRunWindowKey(window)
}

func scheduledWindowFromState(record scheduledWindowState) (scheduler.Window, error) {
if record.Period == "" || record.From.IsZero() || !record.To.After(record.From) {
return scheduler.Window{}, fmt.Errorf("invalid scheduled window state")
Expand Down Expand Up @@ -306,6 +311,7 @@ func (app *app) registerScheduledWindow(window scheduler.Window, dueAt time.Time
return false, nil
}
state.Windows[key] = scheduledWindowState{
RunID: scheduledRunID(window),
Expr: window.Expr,
Period: window.Period,
From: window.From,
Expand Down Expand Up @@ -338,7 +344,10 @@ func (app *app) markScheduledRunWaitingX(window scheduler.Window, trigger string
return false, nil
}
if !ok {
record = scheduledWindowState{Expr: window.Expr, Period: window.Period, From: window.From, To: window.To, DueAt: window.To}
record = scheduledWindowState{RunID: scheduledRunID(window), Expr: window.Expr, Period: window.Period, From: window.From, To: window.To, DueAt: window.To}
}
if record.RunID == "" {
record.RunID = scheduledRunID(window)
}
record.Status = scheduledStatusWaitingX
record.Trigger = trigger
Expand Down Expand Up @@ -378,7 +387,10 @@ func (app *app) acquireScheduledRunWindow(window scheduler.Window, trigger strin
return false, nil
}
if !ok {
record = scheduledWindowState{Expr: window.Expr, Period: window.Period, From: window.From, To: window.To, DueAt: window.To}
record = scheduledWindowState{RunID: scheduledRunID(window), Expr: window.Expr, Period: window.Period, From: window.From, To: window.To, DueAt: window.To}
}
if record.RunID == "" {
record.RunID = scheduledRunID(window)
}
emailAlreadySent = !record.EmailSentAt.IsZero()
record.Status = scheduledStatusRunning
Expand Down
4 changes: 4 additions & 0 deletions configs/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,10 @@ fetch:

watch:
article_concurrency: 8
# Normal runs only fetch changed/new article bodies. A bounded oldest-first
# audit still detects silent body changes without downloading the full corpus.
deep_verify_interval: 24h
deep_verify_batch_size: 48
sites:
- name: Anthropic Claude Support
type: anthropic_support
Expand Down
33 changes: 29 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ const (
DefaultWatchBrowseboxProxyPort = 17997
DefaultWatchBrowseboxControllerPort = 17998
DefaultWatchArticleConcurrency = 8
DefaultWatchDeepVerifyInterval = 24 * time.Hour
DefaultWatchDeepVerifyBatchSize = 48
DefaultXRefreshWaitTimeout = 10 * time.Minute
DefaultXRefreshWaitInterval = 5 * time.Second
DefaultXRefreshReconcileInterval = time.Minute
Expand Down Expand Up @@ -137,10 +139,14 @@ type FetchConfig struct {
}

type WatchConfig struct {
Sites []WatchSite `yaml:"sites"`
ArticleConcurrencyRaw *int `yaml:"article_concurrency"`
ArticleConcurrency int `yaml:"-"`
ProxyProvider WatchProxyProvider `yaml:"proxy_provider"`
Sites []WatchSite `yaml:"sites"`
ArticleConcurrencyRaw *int `yaml:"article_concurrency"`
ArticleConcurrency int `yaml:"-"`
DeepVerifyIntervalRaw string `yaml:"deep_verify_interval"`
DeepVerifyInterval time.Duration `yaml:"-"`
DeepVerifyBatchSizeRaw *int `yaml:"deep_verify_batch_size"`
DeepVerifyBatchSize int `yaml:"-"`
ProxyProvider WatchProxyProvider `yaml:"proxy_provider"`
}

type WatchProxyProvider struct {
Expand Down Expand Up @@ -359,6 +365,19 @@ func applyWatchDefaults(watch *WatchConfig) error {
} else {
watch.ArticleConcurrency = *watch.ArticleConcurrencyRaw
}
if strings.TrimSpace(watch.DeepVerifyIntervalRaw) == "" {
watch.DeepVerifyIntervalRaw = DefaultWatchDeepVerifyInterval.String()
}
deepVerifyInterval, err := time.ParseDuration(strings.TrimSpace(watch.DeepVerifyIntervalRaw))
if err != nil {
return fmt.Errorf("parse watch.deep_verify_interval: %w", err)
}
watch.DeepVerifyInterval = deepVerifyInterval
if watch.DeepVerifyBatchSizeRaw == nil {
watch.DeepVerifyBatchSize = DefaultWatchDeepVerifyBatchSize
} else {
watch.DeepVerifyBatchSize = *watch.DeepVerifyBatchSizeRaw
}
provider := &watch.ProxyProvider
if !provider.Enabled {
return nil
Expand Down Expand Up @@ -639,6 +658,12 @@ func (cfg *Config) Validate() error {
if cfg.Watch.ArticleConcurrency < 1 {
return fmt.Errorf("validate watch.article_concurrency: must be at least 1")
}
if cfg.Watch.DeepVerifyInterval <= 0 {
return fmt.Errorf("validate watch.deep_verify_interval: must be greater than 0")
}
if cfg.Watch.DeepVerifyBatchSize < 1 {
return fmt.Errorf("validate watch.deep_verify_batch_size: must be at least 1")
}
for i, site := range cfg.Watch.Sites {
if err := validateWatchSite(i, site); err != nil {
return err
Expand Down
52 changes: 52 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1508,6 +1508,58 @@ ai: {}
}
}

func TestLoadAppliesWatchDeepVerificationDefaults(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
content := `sources: []
keywords: []
fetch: {}
watch: {}
email: {}
schedule: []
output: {}
proxy: {}
ai: {}
`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Watch.DeepVerifyInterval != 24*time.Hour || cfg.Watch.DeepVerifyBatchSize != 48 {
t.Fatalf("watch deep verification = %s/%d", cfg.Watch.DeepVerifyInterval, cfg.Watch.DeepVerifyBatchSize)
}
}

func TestLoadAppliesConfiguredWatchDeepVerification(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
content := `sources: []
keywords: []
fetch: {}
watch:
deep_verify_interval: 12h
deep_verify_batch_size: 16
email: {}
schedule: []
output: {}
proxy: {}
ai: {}
`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Watch.DeepVerifyInterval != 12*time.Hour || cfg.Watch.DeepVerifyBatchSize != 16 {
t.Fatalf("watch deep verification = %s/%d", cfg.Watch.DeepVerifyInterval, cfg.Watch.DeepVerifyBatchSize)
}
}

func TestLoadRejectsInvalidWatchArticleConcurrency(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
Expand Down
19 changes: 18 additions & 1 deletion internal/fetcher/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ type sourceFetchResult struct {
Candidates []fetchedCandidate
FetchedCount int
RedditRateLimitWait time.Duration
FetchDuration time.Duration
ResponseBytes int64
CacheStatus string
}

type sourceFetchFunc func(context.Context, config.Source, []string, time.Time) (sourceFetchResult, error)
Expand All @@ -121,6 +124,7 @@ type curlFetchFunc func(context.Context, string) ([]byte, error)
type Client struct {
httpClient *http.Client
fetchCurl curlFetchFunc
rssCache *rssFeedCache
}

func NewClient(httpClient *http.Client) *Client {
Expand All @@ -130,6 +134,13 @@ func NewClient(httpClient *http.Client) *Client {
return &Client{httpClient: httpClient, fetchCurl: fetchFeedWithCurlContext}
}

func (c *Client) SetRSSCacheDir(dir string) {
if c == nil || strings.TrimSpace(dir) == "" {
return
}
c.rssCache = newRSSFeedCache(dir)
}

func fetchRSSSource(ctx context.Context, src config.Source, keywords []string, since time.Time) (sourceFetchResult, error) {
return FetchRSSContext(ctx, src, keywords, since)
}
Expand Down Expand Up @@ -367,8 +378,14 @@ func (acc *sourceStatsAccumulator) statForArticle(article model.Article, fallbac
}

func (acc *sourceStatsAccumulator) countFetched(result sourceFetchResult) {
entry := acc.statForArticle(model.Article{}, result.Source)
entry.FetchDurationMS += result.FetchDuration.Milliseconds()
entry.ResponseBytes += result.ResponseBytes
if result.CacheStatus != "" {
entry.CacheStatus = result.CacheStatus
}
if result.FetchedCount > 0 {
acc.statForArticle(model.Article{}, result.Source).Fetched += result.FetchedCount
entry.Fetched += result.FetchedCount
return
}
for _, candidate := range result.Candidates {
Expand Down
15 changes: 9 additions & 6 deletions internal/fetcher/rss.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ func (c *Client) fetchRSSContextWithOpenGraphOptions(ctx context.Context, source
fp := gofeed.NewParser()
fp.Client = c.httpClient

feed, headers, err := c.fetchRSSFeed(ctx, fetchSource, fp)
fetchStarted := time.Now()
feed, headers, responseBytes, cacheStatus, err := c.fetchRSSFeed(ctx, fetchSource, source.URL, fp)
if err != nil {
if !shouldFallbackToCurl(source, err) {
return sourceFetchResult{}, err
Expand All @@ -87,9 +88,11 @@ func (c *Client) fetchRSSContextWithOpenGraphOptions(ctx context.Context, source
return sourceFetchResult{}, err
}
headers = nil
responseBytes = int64(len(body))
cacheStatus = "curl"
}

result := sourceFetchResult{Source: source, FetchedCount: len(feed.Items)}
result := sourceFetchResult{Source: source, FetchedCount: len(feed.Items), FetchDuration: time.Since(fetchStarted), ResponseBytes: responseBytes, CacheStatus: cacheStatus}
isRedditRSS := isRedditURL(source.URL)
if isRedditRSS {
result.RedditRateLimitWait = redditRateLimitWaitFromHeader(headers)
Expand Down Expand Up @@ -185,12 +188,12 @@ func authenticatedRSSURL(source config.Source) (string, error) {
return parsedURL.String(), nil
}

func (c *Client) fetchRSSFeed(ctx context.Context, source config.Source, fp *gofeed.Parser) (*gofeed.Feed, http.Header, error) {
func (c *Client) fetchRSSFeed(ctx context.Context, source config.Source, cacheKeyURL string, fp *gofeed.Parser) (*gofeed.Feed, http.Header, int64, string, error) {
if !isRedditURL(source.URL) {
feed, err := fp.ParseURLWithContext(source.URL, ctx)
return feed, nil, err
return c.fetchRSSFeedHTTP(ctx, source.URL, cacheKeyURL, fp)
}
return c.fetchRSSFeedWithHeaders(ctx, source.URL, fp)
feed, headers, err := c.fetchRSSFeedWithHeaders(ctx, source.URL, fp)
return feed, headers, 0, "network", err
}

func (c *Client) fetchRSSFeedWithHeaders(ctx context.Context, feedURL string, fp *gofeed.Parser) (*gofeed.Feed, http.Header, error) {
Expand Down
Loading