@@ -212,7 +212,17 @@ func checkTargetRegex(t db.MonitorTarget, body []byte) {
212212 }
213213
214214 text := string (body )
215- match := re .FindString (text )
215+ // Use ALL matches and pick the MAX (latest date / highest version / lexically
216+ // largest), not FindString's first match. A page that lists multiple matching
217+ // items (e.g. blog posts) often re-orders them between requests — picking the
218+ // first match flapped the baseline, e.g. blackline.com/blog/ alerting 23 → 24 →
219+ // 23 → 24 inside 4 minutes as the featured post shuffled. The max is stable
220+ // regardless of ordering.
221+ matches := re .FindAllString (text , - 1 )
222+ if len (matches ) == 0 {
223+ return // nothing to compare; don't churn the baseline on a transient empty fetch
224+ }
225+ match := maxRegexMatch (matches )
216226
217227 // Switched from hash strategy — old last_hash is hex; establish fresh regex baseline.
218228 baseline := t .LastHash
@@ -226,10 +236,21 @@ func checkTargetRegex(t db.MonitorTarget, body []byte) {
226236 return
227237 }
228238
229- if match == baseline {
239+ // Forward-only watermark: a regex monitor is for "latest X" — a backward jump
240+ // (older date / lower version) is almost always page-ordering noise, not a real
241+ // rollback. Skip the alert AND don't advance the baseline so the baseline stays
242+ // at the genuine high-water-mark; the next refresh comparing against it will be
243+ // stable. Use compareWatchValue which parses dates (ISO + "Jan 2, 2006") and
244+ // falls back to lexical compare for arbitrary patterns.
245+ cmp := compareWatchValue (match , baseline )
246+ if cmp == 0 {
230247 _ = db .UpdateMonitorTargetLastRun (t .ID , match , false )
231248 return
232249 }
250+ if cmp < 0 {
251+ // Match went backwards — keep the baseline, don't alert, don't advance.
252+ return
253+ }
233254
234255 logger .GetLogger ().Infof ("[URL-MONITOR] Change detected for %s (regex)" , t .URL )
235256
@@ -267,3 +288,51 @@ func checkTargetRegex(t db.MonitorTarget, body []byte) {
267288 logger .GetLogger ().Infof ("[URL-MONITOR] Alert: %s" , msg )
268289 utils .SendMonitorWebhook (msg )
269290}
291+
292+ // maxRegexMatch returns the "largest" of a non-empty slice of regex matches,
293+ // using compareWatchValue (date-aware where possible, lexical otherwise). This
294+ // makes "latest update" monitoring stable on pages that list multiple matching
295+ // items in a varying order.
296+ func maxRegexMatch (matches []string ) string {
297+ best := matches [0 ]
298+ for _ , m := range matches [1 :] {
299+ if compareWatchValue (m , best ) > 0 {
300+ best = m
301+ }
302+ }
303+ return best
304+ }
305+
306+ // compareWatchValue returns >0 if a>b, <0 if a<b, 0 if equal. Tries common date
307+ // formats first so chronological ordering wins ("Jul 1, 2026" > "Jun 30, 2026"
308+ // even though lexically Jul<Jun). Falls back to plain string compare for
309+ // arbitrary patterns (version strings, hashes, etc.) where ASCII order is fine.
310+ func compareWatchValue (a , b string ) int {
311+ ta , aOK := tryParseWatchDate (a )
312+ tb , bOK := tryParseWatchDate (b )
313+ if aOK && bOK {
314+ return ta .Compare (tb )
315+ }
316+ return strings .Compare (a , b )
317+ }
318+
319+ func tryParseWatchDate (s string ) (time.Time , bool ) {
320+ s = strings .TrimSpace (s )
321+ if s == "" {
322+ return time.Time {}, false
323+ }
324+ formats := []string {
325+ "2006-01-02" ,
326+ "Jan 2, 2006" ,
327+ "January 2, 2006" ,
328+ "Jan 02, 2006" ,
329+ "02 Jan 2006" ,
330+ time .RFC3339 ,
331+ }
332+ for _ , f := range formats {
333+ if t , err := time .Parse (f , s ); err == nil {
334+ return t , true
335+ }
336+ }
337+ return time.Time {}, false
338+ }
0 commit comments