processor.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package processor
  4. import (
  5. "errors"
  6. "fmt"
  7. "log/slog"
  8. "regexp"
  9. "slices"
  10. "strconv"
  11. "strings"
  12. "time"
  13. "miniflux.app/v2/internal/config"
  14. "miniflux.app/v2/internal/metric"
  15. "miniflux.app/v2/internal/model"
  16. "miniflux.app/v2/internal/reader/fetcher"
  17. "miniflux.app/v2/internal/reader/readingtime"
  18. "miniflux.app/v2/internal/reader/rewrite"
  19. "miniflux.app/v2/internal/reader/sanitizer"
  20. "miniflux.app/v2/internal/reader/scraper"
  21. "miniflux.app/v2/internal/reader/urlcleaner"
  22. "miniflux.app/v2/internal/storage"
  23. "github.com/PuerkitoBio/goquery"
  24. "github.com/tdewolff/minify/v2"
  25. "github.com/tdewolff/minify/v2/html"
  26. )
  27. var (
  28. youtubeRegex = regexp.MustCompile(`youtube\.com/watch\?v=(.*)$`)
  29. nebulaRegex = regexp.MustCompile(`^https://nebula\.tv`)
  30. odyseeRegex = regexp.MustCompile(`^https://odysee\.com`)
  31. iso8601Regex = regexp.MustCompile(`^P((?P<year>\d+)Y)?((?P<month>\d+)M)?((?P<week>\d+)W)?((?P<day>\d+)D)?(T((?P<hour>\d+)H)?((?P<minute>\d+)M)?((?P<second>\d+)S)?)?$`)
  32. customReplaceRuleRegex = regexp.MustCompile(`rewrite\("(.*)"\|"(.*)"\)`)
  33. )
  34. // ProcessFeedEntries downloads original web page for entries and apply filters.
  35. func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.User, forceRefresh bool) {
  36. var filteredEntries model.Entries
  37. // Process older entries first
  38. for i := len(feed.Entries) - 1; i >= 0; i-- {
  39. entry := feed.Entries[i]
  40. slog.Debug("Processing entry",
  41. slog.Int64("user_id", user.ID),
  42. slog.String("entry_url", entry.URL),
  43. slog.String("entry_hash", entry.Hash),
  44. slog.String("entry_title", entry.Title),
  45. slog.Int64("feed_id", feed.ID),
  46. slog.String("feed_url", feed.FeedURL),
  47. )
  48. if isBlockedEntry(feed, entry, user) || !isAllowedEntry(feed, entry, user) || !isRecentEntry(entry) {
  49. continue
  50. }
  51. if cleanedURL, err := urlcleaner.RemoveTrackingParameters(entry.URL); err == nil {
  52. entry.URL = cleanedURL
  53. }
  54. pageBaseURL := ""
  55. rewrittenURL := rewriteEntryURL(feed, entry)
  56. entryIsNew := store.IsNewEntry(feed.ID, entry.Hash)
  57. if feed.Crawler && (entryIsNew || forceRefresh) {
  58. slog.Debug("Scraping entry",
  59. slog.Int64("user_id", user.ID),
  60. slog.String("entry_url", entry.URL),
  61. slog.String("entry_hash", entry.Hash),
  62. slog.String("entry_title", entry.Title),
  63. slog.Int64("feed_id", feed.ID),
  64. slog.String("feed_url", feed.FeedURL),
  65. slog.Bool("entry_is_new", entryIsNew),
  66. slog.Bool("force_refresh", forceRefresh),
  67. slog.String("rewritten_url", rewrittenURL),
  68. )
  69. startTime := time.Now()
  70. requestBuilder := fetcher.NewRequestBuilder()
  71. requestBuilder.WithUserAgent(feed.UserAgent, config.Opts.HTTPClientUserAgent())
  72. requestBuilder.WithCookie(feed.Cookie)
  73. requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
  74. requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
  75. requestBuilder.UseProxy(feed.FetchViaProxy)
  76. requestBuilder.IgnoreTLSErrors(feed.AllowSelfSignedCertificates)
  77. requestBuilder.DisableHTTP2(feed.DisableHTTP2)
  78. scrapedPageBaseURL, extractedContent, scraperErr := scraper.ScrapeWebsite(
  79. requestBuilder,
  80. rewrittenURL,
  81. feed.ScraperRules,
  82. )
  83. if scrapedPageBaseURL != "" {
  84. pageBaseURL = scrapedPageBaseURL
  85. }
  86. if config.Opts.HasMetricsCollector() {
  87. status := "success"
  88. if scraperErr != nil {
  89. status = "error"
  90. }
  91. metric.ScraperRequestDuration.WithLabelValues(status).Observe(time.Since(startTime).Seconds())
  92. }
  93. if scraperErr != nil {
  94. slog.Warn("Unable to scrape entry",
  95. slog.Int64("user_id", user.ID),
  96. slog.String("entry_url", entry.URL),
  97. slog.Int64("feed_id", feed.ID),
  98. slog.String("feed_url", feed.FeedURL),
  99. slog.Any("error", scraperErr),
  100. )
  101. } else if extractedContent != "" {
  102. // We replace the entry content only if the scraper doesn't return any error.
  103. entry.Content = minifyEntryContent(extractedContent)
  104. }
  105. }
  106. rewrite.Rewriter(rewrittenURL, entry, feed.RewriteRules)
  107. if pageBaseURL == "" {
  108. pageBaseURL = rewrittenURL
  109. }
  110. // The sanitizer should always run at the end of the process to make sure unsafe HTML is filtered out.
  111. entry.Content = sanitizer.Sanitize(pageBaseURL, entry.Content)
  112. updateEntryReadingTime(store, feed, entry, entryIsNew, user)
  113. filteredEntries = append(filteredEntries, entry)
  114. }
  115. feed.Entries = filteredEntries
  116. }
  117. func isBlockedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool {
  118. if user.BlockFilterEntryRules != "" {
  119. rules := strings.Split(user.BlockFilterEntryRules, "\n")
  120. for _, rule := range rules {
  121. parts := strings.SplitN(rule, "=", 2)
  122. var match bool
  123. switch parts[0] {
  124. case "EntryTitle":
  125. match, _ = regexp.MatchString(parts[1], entry.Title)
  126. case "EntryURL":
  127. match, _ = regexp.MatchString(parts[1], entry.URL)
  128. case "EntryCommentsURL":
  129. match, _ = regexp.MatchString(parts[1], entry.CommentsURL)
  130. case "EntryContent":
  131. match, _ = regexp.MatchString(parts[1], entry.Content)
  132. case "EntryAuthor":
  133. match, _ = regexp.MatchString(parts[1], entry.Author)
  134. case "EntryTag":
  135. containsTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
  136. match, _ = regexp.MatchString(parts[1], tag)
  137. return match
  138. })
  139. if containsTag {
  140. match = true
  141. }
  142. }
  143. if match {
  144. slog.Debug("Blocking entry based on rule",
  145. slog.String("entry_url", entry.URL),
  146. slog.Int64("feed_id", feed.ID),
  147. slog.String("feed_url", feed.FeedURL),
  148. slog.String("rule", rule),
  149. )
  150. return true
  151. }
  152. }
  153. }
  154. if feed.BlocklistRules == "" {
  155. return false
  156. }
  157. compiledBlocklist, err := regexp.Compile(feed.BlocklistRules)
  158. if err != nil {
  159. slog.Debug("Failed on regexp compilation",
  160. slog.String("pattern", feed.BlocklistRules),
  161. slog.Any("error", err),
  162. )
  163. return false
  164. }
  165. containsBlockedTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
  166. return compiledBlocklist.MatchString(tag)
  167. })
  168. if compiledBlocklist.MatchString(entry.URL) || compiledBlocklist.MatchString(entry.Title) || compiledBlocklist.MatchString(entry.Author) || containsBlockedTag {
  169. slog.Debug("Blocking entry based on rule",
  170. slog.String("entry_url", entry.URL),
  171. slog.Int64("feed_id", feed.ID),
  172. slog.String("feed_url", feed.FeedURL),
  173. slog.String("rule", feed.BlocklistRules),
  174. )
  175. return true
  176. }
  177. return false
  178. }
  179. func isAllowedEntry(feed *model.Feed, entry *model.Entry, user *model.User) bool {
  180. if user.KeepFilterEntryRules != "" {
  181. rules := strings.Split(user.KeepFilterEntryRules, "\n")
  182. for _, rule := range rules {
  183. parts := strings.SplitN(rule, "=", 2)
  184. var match bool
  185. switch parts[0] {
  186. case "EntryTitle":
  187. match, _ = regexp.MatchString(parts[1], entry.Title)
  188. case "EntryURL":
  189. match, _ = regexp.MatchString(parts[1], entry.URL)
  190. case "EntryCommentsURL":
  191. match, _ = regexp.MatchString(parts[1], entry.CommentsURL)
  192. case "EntryContent":
  193. match, _ = regexp.MatchString(parts[1], entry.Content)
  194. case "EntryAuthor":
  195. match, _ = regexp.MatchString(parts[1], entry.Author)
  196. case "EntryTag":
  197. containsTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
  198. match, _ = regexp.MatchString(parts[1], tag)
  199. return match
  200. })
  201. if containsTag {
  202. match = true
  203. }
  204. }
  205. if match {
  206. slog.Debug("Allowing entry based on rule",
  207. slog.String("entry_url", entry.URL),
  208. slog.Int64("feed_id", feed.ID),
  209. slog.String("feed_url", feed.FeedURL),
  210. slog.String("rule", rule),
  211. )
  212. return true
  213. }
  214. }
  215. return false
  216. }
  217. if feed.KeeplistRules == "" {
  218. return true
  219. }
  220. compiledKeeplist, err := regexp.Compile(feed.KeeplistRules)
  221. if err != nil {
  222. slog.Debug("Failed on regexp compilation",
  223. slog.String("pattern", feed.KeeplistRules),
  224. slog.Any("error", err),
  225. )
  226. return false
  227. }
  228. containsAllowedTag := slices.ContainsFunc(entry.Tags, func(tag string) bool {
  229. return compiledKeeplist.MatchString(tag)
  230. })
  231. if compiledKeeplist.MatchString(entry.URL) || compiledKeeplist.MatchString(entry.Title) || compiledKeeplist.MatchString(entry.Author) || containsAllowedTag {
  232. slog.Debug("Allow entry based on rule",
  233. slog.String("entry_url", entry.URL),
  234. slog.Int64("feed_id", feed.ID),
  235. slog.String("feed_url", feed.FeedURL),
  236. slog.String("rule", feed.KeeplistRules),
  237. )
  238. return true
  239. }
  240. return false
  241. }
  242. // ProcessEntryWebPage downloads the entry web page and apply rewrite rules.
  243. func ProcessEntryWebPage(feed *model.Feed, entry *model.Entry, user *model.User) error {
  244. startTime := time.Now()
  245. rewrittenEntryURL := rewriteEntryURL(feed, entry)
  246. requestBuilder := fetcher.NewRequestBuilder()
  247. requestBuilder.WithUserAgent(feed.UserAgent, config.Opts.HTTPClientUserAgent())
  248. requestBuilder.WithCookie(feed.Cookie)
  249. requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
  250. requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
  251. requestBuilder.UseProxy(feed.FetchViaProxy)
  252. requestBuilder.IgnoreTLSErrors(feed.AllowSelfSignedCertificates)
  253. requestBuilder.DisableHTTP2(feed.DisableHTTP2)
  254. pageBaseURL, extractedContent, scraperErr := scraper.ScrapeWebsite(
  255. requestBuilder,
  256. rewrittenEntryURL,
  257. feed.ScraperRules,
  258. )
  259. if config.Opts.HasMetricsCollector() {
  260. status := "success"
  261. if scraperErr != nil {
  262. status = "error"
  263. }
  264. metric.ScraperRequestDuration.WithLabelValues(status).Observe(time.Since(startTime).Seconds())
  265. }
  266. if scraperErr != nil {
  267. return scraperErr
  268. }
  269. if extractedContent != "" {
  270. entry.Content = minifyEntryContent(extractedContent)
  271. if user.ShowReadingTime {
  272. entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
  273. }
  274. }
  275. rewrite.Rewriter(rewrittenEntryURL, entry, entry.Feed.RewriteRules)
  276. entry.Content = sanitizer.Sanitize(pageBaseURL, entry.Content)
  277. return nil
  278. }
  279. func rewriteEntryURL(feed *model.Feed, entry *model.Entry) string {
  280. var rewrittenURL = entry.URL
  281. if feed.UrlRewriteRules != "" {
  282. parts := customReplaceRuleRegex.FindStringSubmatch(feed.UrlRewriteRules)
  283. if len(parts) >= 3 {
  284. re, err := regexp.Compile(parts[1])
  285. if err != nil {
  286. slog.Error("Failed on regexp compilation",
  287. slog.String("url_rewrite_rules", feed.UrlRewriteRules),
  288. slog.Any("error", err),
  289. )
  290. return rewrittenURL
  291. }
  292. rewrittenURL = re.ReplaceAllString(entry.URL, parts[2])
  293. slog.Debug("Rewriting entry URL",
  294. slog.String("original_entry_url", entry.URL),
  295. slog.String("rewritten_entry_url", rewrittenURL),
  296. slog.Int64("feed_id", feed.ID),
  297. slog.String("feed_url", feed.FeedURL),
  298. )
  299. } else {
  300. slog.Debug("Cannot find search and replace terms for replace rule",
  301. slog.String("original_entry_url", entry.URL),
  302. slog.String("rewritten_entry_url", rewrittenURL),
  303. slog.Int64("feed_id", feed.ID),
  304. slog.String("feed_url", feed.FeedURL),
  305. slog.String("url_rewrite_rules", feed.UrlRewriteRules),
  306. )
  307. }
  308. }
  309. return rewrittenURL
  310. }
  311. func updateEntryReadingTime(store *storage.Storage, feed *model.Feed, entry *model.Entry, entryIsNew bool, user *model.User) {
  312. if !user.ShowReadingTime {
  313. slog.Debug("Skip reading time estimation for this user", slog.Int64("user_id", user.ID))
  314. return
  315. }
  316. if shouldFetchYouTubeWatchTime(entry) {
  317. if entryIsNew {
  318. watchTime, err := fetchYouTubeWatchTime(entry.URL)
  319. if err != nil {
  320. slog.Warn("Unable to fetch YouTube watch time",
  321. slog.Int64("user_id", user.ID),
  322. slog.Int64("entry_id", entry.ID),
  323. slog.String("entry_url", entry.URL),
  324. slog.Int64("feed_id", feed.ID),
  325. slog.String("feed_url", feed.FeedURL),
  326. slog.Any("error", err),
  327. )
  328. }
  329. entry.ReadingTime = watchTime
  330. } else {
  331. entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
  332. }
  333. }
  334. if shouldFetchNebulaWatchTime(entry) {
  335. if entryIsNew {
  336. watchTime, err := fetchNebulaWatchTime(entry.URL)
  337. if err != nil {
  338. slog.Warn("Unable to fetch Nebula watch time",
  339. slog.Int64("user_id", user.ID),
  340. slog.Int64("entry_id", entry.ID),
  341. slog.String("entry_url", entry.URL),
  342. slog.Int64("feed_id", feed.ID),
  343. slog.String("feed_url", feed.FeedURL),
  344. slog.Any("error", err),
  345. )
  346. }
  347. entry.ReadingTime = watchTime
  348. } else {
  349. entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
  350. }
  351. }
  352. if shouldFetchOdyseeWatchTime(entry) {
  353. if entryIsNew {
  354. watchTime, err := fetchOdyseeWatchTime(entry.URL)
  355. if err != nil {
  356. slog.Warn("Unable to fetch Odysee watch time",
  357. slog.Int64("user_id", user.ID),
  358. slog.Int64("entry_id", entry.ID),
  359. slog.String("entry_url", entry.URL),
  360. slog.Int64("feed_id", feed.ID),
  361. slog.String("feed_url", feed.FeedURL),
  362. slog.Any("error", err),
  363. )
  364. }
  365. entry.ReadingTime = watchTime
  366. } else {
  367. entry.ReadingTime = store.GetReadTime(feed.ID, entry.Hash)
  368. }
  369. }
  370. // Handle YT error case and non-YT entries.
  371. if entry.ReadingTime == 0 {
  372. entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
  373. }
  374. }
  375. func shouldFetchYouTubeWatchTime(entry *model.Entry) bool {
  376. if !config.Opts.FetchYouTubeWatchTime() {
  377. return false
  378. }
  379. matches := youtubeRegex.FindStringSubmatch(entry.URL)
  380. urlMatchesYouTubePattern := len(matches) == 2
  381. return urlMatchesYouTubePattern
  382. }
  383. func shouldFetchNebulaWatchTime(entry *model.Entry) bool {
  384. if !config.Opts.FetchNebulaWatchTime() {
  385. return false
  386. }
  387. matches := nebulaRegex.FindStringSubmatch(entry.URL)
  388. return matches != nil
  389. }
  390. func shouldFetchOdyseeWatchTime(entry *model.Entry) bool {
  391. if !config.Opts.FetchOdyseeWatchTime() {
  392. return false
  393. }
  394. matches := odyseeRegex.FindStringSubmatch(entry.URL)
  395. return matches != nil
  396. }
  397. func fetchYouTubeWatchTime(websiteURL string) (int, error) {
  398. requestBuilder := fetcher.NewRequestBuilder()
  399. requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
  400. requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
  401. responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
  402. defer responseHandler.Close()
  403. if localizedError := responseHandler.LocalizedError(); localizedError != nil {
  404. slog.Warn("Unable to fetch YouTube page", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
  405. return 0, localizedError.Error()
  406. }
  407. doc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
  408. if docErr != nil {
  409. return 0, docErr
  410. }
  411. durs, exists := doc.Find(`meta[itemprop="duration"]`).First().Attr("content")
  412. if !exists {
  413. return 0, errors.New("duration has not found")
  414. }
  415. dur, err := parseISO8601(durs)
  416. if err != nil {
  417. return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
  418. }
  419. return int(dur.Minutes()), nil
  420. }
  421. func fetchNebulaWatchTime(websiteURL string) (int, error) {
  422. requestBuilder := fetcher.NewRequestBuilder()
  423. requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
  424. requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
  425. responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
  426. defer responseHandler.Close()
  427. if localizedError := responseHandler.LocalizedError(); localizedError != nil {
  428. slog.Warn("Unable to fetch Nebula watch time", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
  429. return 0, localizedError.Error()
  430. }
  431. doc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
  432. if docErr != nil {
  433. return 0, docErr
  434. }
  435. durs, exists := doc.Find(`meta[property="video:duration"]`).First().Attr("content")
  436. // durs contains video watch time in seconds
  437. if !exists {
  438. return 0, errors.New("duration has not found")
  439. }
  440. dur, err := strconv.ParseInt(durs, 10, 64)
  441. if err != nil {
  442. return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
  443. }
  444. return int(dur / 60), nil
  445. }
  446. func fetchOdyseeWatchTime(websiteURL string) (int, error) {
  447. requestBuilder := fetcher.NewRequestBuilder()
  448. requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
  449. requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
  450. responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(websiteURL))
  451. defer responseHandler.Close()
  452. if localizedError := responseHandler.LocalizedError(); localizedError != nil {
  453. slog.Warn("Unable to fetch Odysee watch time", slog.String("website_url", websiteURL), slog.Any("error", localizedError.Error()))
  454. return 0, localizedError.Error()
  455. }
  456. doc, docErr := goquery.NewDocumentFromReader(responseHandler.Body(config.Opts.HTTPClientMaxBodySize()))
  457. if docErr != nil {
  458. return 0, docErr
  459. }
  460. durs, exists := doc.Find(`meta[property="og:video:duration"]`).First().Attr("content")
  461. // durs contains video watch time in seconds
  462. if !exists {
  463. return 0, errors.New("duration has not found")
  464. }
  465. dur, err := strconv.ParseInt(durs, 10, 64)
  466. if err != nil {
  467. return 0, fmt.Errorf("unable to parse duration %s: %v", durs, err)
  468. }
  469. return int(dur / 60), nil
  470. }
  471. // parseISO8601 parses an ISO 8601 duration string.
  472. func parseISO8601(from string) (time.Duration, error) {
  473. var match []string
  474. var d time.Duration
  475. if iso8601Regex.MatchString(from) {
  476. match = iso8601Regex.FindStringSubmatch(from)
  477. } else {
  478. return 0, errors.New("could not parse duration string")
  479. }
  480. for i, name := range iso8601Regex.SubexpNames() {
  481. part := match[i]
  482. if i == 0 || name == "" || part == "" {
  483. continue
  484. }
  485. val, err := strconv.ParseInt(part, 10, 64)
  486. if err != nil {
  487. return 0, err
  488. }
  489. switch name {
  490. case "hour":
  491. d += (time.Duration(val) * time.Hour)
  492. case "minute":
  493. d += (time.Duration(val) * time.Minute)
  494. case "second":
  495. d += (time.Duration(val) * time.Second)
  496. default:
  497. return 0, fmt.Errorf("unknown field %s", name)
  498. }
  499. }
  500. return d, nil
  501. }
  502. func isRecentEntry(entry *model.Entry) bool {
  503. if config.Opts.FilterEntryMaxAgeDays() == 0 || entry.Date.After(time.Now().AddDate(0, 0, -config.Opts.FilterEntryMaxAgeDays())) {
  504. return true
  505. }
  506. return false
  507. }
  508. func minifyEntryContent(entryContent string) string {
  509. m := minify.New()
  510. // Options required to avoid breaking the HTML content.
  511. m.Add("text/html", &html.Minifier{
  512. KeepEndTags: true,
  513. KeepQuotes: true,
  514. })
  515. if minifiedHTML, err := m.String("text/html", entryContent); err == nil {
  516. entryContent = minifiedHTML
  517. }
  518. return entryContent
  519. }