adapter.go 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package json // import "miniflux.app/v2/internal/reader/json"
  4. import (
  5. "cmp"
  6. "html"
  7. "log/slog"
  8. "slices"
  9. "strings"
  10. "time"
  11. "miniflux.app/v2/internal/crypto"
  12. "miniflux.app/v2/internal/model"
  13. "miniflux.app/v2/internal/reader/date"
  14. "miniflux.app/v2/internal/reader/language"
  15. "miniflux.app/v2/internal/reader/sanitizer"
  16. "miniflux.app/v2/internal/urllib"
  17. )
  18. type JSONAdapter struct {
  19. jsonFeed *JSONFeed
  20. }
  21. func NewJSONAdapter(jsonFeed *JSONFeed) *JSONAdapter {
  22. return &JSONAdapter{jsonFeed}
  23. }
  24. func (j *JSONAdapter) BuildFeed(baseURL string) *model.Feed {
  25. feed := &model.Feed{
  26. Title: strings.TrimSpace(j.jsonFeed.Title),
  27. FeedURL: strings.TrimSpace(j.jsonFeed.FeedURL),
  28. SiteURL: strings.TrimSpace(j.jsonFeed.HomePageURL),
  29. Description: strings.TrimSpace(j.jsonFeed.Description),
  30. Language: language.Normalize(j.jsonFeed.Language),
  31. }
  32. if feed.FeedURL == "" {
  33. feed.FeedURL = strings.TrimSpace(baseURL)
  34. }
  35. // Fallback to the feed URL if the site URL is empty.
  36. if feed.SiteURL == "" {
  37. feed.SiteURL = feed.FeedURL
  38. }
  39. if feedURL, err := urllib.ResolveToAbsoluteURL(baseURL, feed.FeedURL); err == nil {
  40. feed.FeedURL = feedURL
  41. }
  42. if siteURL, err := urllib.ResolveToAbsoluteURL(baseURL, feed.SiteURL); err == nil {
  43. feed.SiteURL = siteURL
  44. }
  45. // Fallback to the feed URL if the title is empty.
  46. if feed.Title == "" {
  47. feed.Title = feed.SiteURL
  48. }
  49. // Populate the icon URL if present.
  50. for _, iconURL := range []string{j.jsonFeed.FaviconURL, j.jsonFeed.IconURL} {
  51. if iconURL = strings.TrimSpace(iconURL); iconURL == "" {
  52. continue
  53. }
  54. if absoluteIconURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, iconURL); err == nil {
  55. feed.IconURL = absoluteIconURL
  56. break
  57. }
  58. }
  59. for _, item := range j.jsonFeed.Items {
  60. entry := model.NewEntry()
  61. // Populate the entry language. Per the JSON Feed spec, an item
  62. // declares a language only when it differs from the primary
  63. // language of the feed.
  64. entry.Language = language.Normalize(item.Language)
  65. if entry.Language == "" {
  66. entry.Language = feed.Language
  67. }
  68. for _, itemURL := range []string{item.URL, item.ExternalURL} {
  69. if itemURL = strings.TrimSpace(itemURL); itemURL == "" {
  70. continue
  71. }
  72. // Make sure the entry URL is absolute.
  73. if entryURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, itemURL); err == nil {
  74. entry.URL = entryURL
  75. break
  76. }
  77. }
  78. entry.Title = strings.TrimSpace(item.Title)
  79. if entry.Title == "" {
  80. // The entry title is optional, so we need to find a fallback.
  81. for _, value := range []string{item.Summary, item.ContentText, item.ContentHTML} {
  82. if value = sanitizer.TruncateHTML(value, 100); value == "" {
  83. continue
  84. }
  85. entry.Title = value
  86. break
  87. }
  88. }
  89. // Fallback to the entry URL if the title is empty.
  90. if entry.Title == "" {
  91. entry.Title = entry.URL
  92. }
  93. // Populate the entry content. content_html is HTML, but content_text
  94. // and summary are plain text (JSON Feed 1.1), so they must be escaped
  95. // before being stored as HTML, otherwise the sanitizer drops any
  96. // markup-like characters they contain.
  97. if contentHTML := strings.TrimSpace(item.ContentHTML); contentHTML != "" {
  98. entry.Content = contentHTML
  99. } else if contentText := strings.TrimSpace(item.ContentText); contentText != "" {
  100. entry.Content = html.EscapeString(contentText)
  101. } else if summary := strings.TrimSpace(item.Summary); summary != "" {
  102. entry.Content = html.EscapeString(summary)
  103. }
  104. // Populate the entry date.
  105. for _, value := range []string{item.DatePublished, item.DateModified} {
  106. if value = strings.TrimSpace(value); value == "" {
  107. continue
  108. }
  109. parsedDate, err := date.Parse(value)
  110. if err != nil {
  111. slog.Debug("Unable to parse date from JSON feed",
  112. slog.String("date", value),
  113. slog.String("url", entry.URL),
  114. slog.Any("error", err),
  115. )
  116. continue
  117. }
  118. entry.Date = parsedDate
  119. break
  120. }
  121. if entry.Date.IsZero() {
  122. entry.Date = time.Now()
  123. }
  124. // Populate the entry author.
  125. authorNames := make([]string, 0, len(j.jsonFeed.Authors)+len(item.Authors)+1+1)
  126. authorNames = appendSorted(authorNames, JSONAuthor.name, j.jsonFeed.Authors...)
  127. authorNames = appendSorted(authorNames, JSONAuthor.name, item.Authors...)
  128. authorNames = appendSorted(authorNames, JSONAuthor.name, item.Author, j.jsonFeed.Author)
  129. entry.Author = strings.Join(authorNames, ", ")
  130. // Populate the entry enclosures.
  131. for _, attachment := range item.Attachments {
  132. attachmentURL := strings.TrimSpace(attachment.URL)
  133. if attachmentURL == "" {
  134. continue
  135. }
  136. absoluteAttachmentURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, attachmentURL)
  137. if err != nil {
  138. slog.Debug("Unable to build absolute URL for attachment",
  139. slog.String("url", attachmentURL),
  140. slog.String("site_url", feed.SiteURL),
  141. slog.Any("error", err),
  142. )
  143. continue
  144. }
  145. entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
  146. URL: absoluteAttachmentURL,
  147. MimeType: attachment.MimeType,
  148. Size: attachment.Size,
  149. })
  150. }
  151. // Populate the entry tags.
  152. entry.Tags = make([]string, 0, len(item.Tags))
  153. entry.Tags = appendSorted(entry.Tags, strings.TrimSpace, item.Tags...)
  154. // Generate a hash for the entry.
  155. for _, value := range []string{item.ID, item.URL, item.ExternalURL, item.ContentText + item.ContentHTML + item.Summary} {
  156. value = strings.TrimSpace(value)
  157. if value != "" {
  158. entry.Hash = crypto.SHA256(value)
  159. break
  160. }
  161. }
  162. feed.Entries = append(feed.Entries, entry)
  163. }
  164. return feed
  165. }
  166. // appendSortedSeq appends elements from "values" slice into "sorted" slice.
  167. // - "fn" applied to every element of "values"
  168. // - elements inserted into "sorted" slice so it stays sorted
  169. // - duplicate elements are not inserted
  170. func appendSorted[I any, O cmp.Ordered](sorted []O, fn func(I) O, values ...I) []O {
  171. var zero O
  172. sorted = slices.Grow(sorted, len(values))
  173. for in := range slices.Values(values) {
  174. out := fn(in)
  175. if out == zero {
  176. continue
  177. }
  178. where, found := slices.BinarySearch(sorted, out)
  179. if found {
  180. continue
  181. }
  182. // Insert sorted to avoid duplicates.
  183. sorted = slices.Insert(sorted, where, out)
  184. }
  185. return sorted
  186. }