atom_10_adapter.go 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package atom // import "miniflux.app/v2/internal/reader/atom"
  4. import (
  5. "log/slog"
  6. "slices"
  7. "sort"
  8. "strconv"
  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/sanitizer"
  15. "miniflux.app/v2/internal/urllib"
  16. )
  17. type Atom10Adapter struct {
  18. atomFeed *Atom10Feed
  19. }
  20. func NewAtom10Adapter(atomFeed *Atom10Feed) *Atom10Adapter {
  21. return &Atom10Adapter{atomFeed}
  22. }
  23. func (a *Atom10Adapter) BuildFeed(baseURL string) *model.Feed {
  24. feed := new(model.Feed)
  25. // Populate the feed URL.
  26. feedURL := a.atomFeed.Links.firstLinkWithRelation("self")
  27. if feedURL != "" {
  28. if absoluteFeedURL, err := urllib.AbsoluteURL(baseURL, feedURL); err == nil {
  29. feed.FeedURL = absoluteFeedURL
  30. }
  31. } else {
  32. feed.FeedURL = baseURL
  33. }
  34. // Populate the site URL.
  35. siteURL := a.atomFeed.Links.OriginalLink()
  36. if siteURL != "" {
  37. if absoluteSiteURL, err := urllib.AbsoluteURL(baseURL, siteURL); err == nil {
  38. feed.SiteURL = absoluteSiteURL
  39. }
  40. } else {
  41. feed.SiteURL = baseURL
  42. }
  43. // Populate the feed title.
  44. feed.Title = a.atomFeed.Title.Body()
  45. if feed.Title == "" {
  46. feed.Title = feed.SiteURL
  47. }
  48. // Populate the feed icon.
  49. if a.atomFeed.Icon != "" {
  50. if absoluteIconURL, err := urllib.AbsoluteURL(feed.SiteURL, a.atomFeed.Icon); err == nil {
  51. feed.IconURL = absoluteIconURL
  52. }
  53. } else if a.atomFeed.Logo != "" {
  54. if absoluteLogoURL, err := urllib.AbsoluteURL(feed.SiteURL, a.atomFeed.Logo); err == nil {
  55. feed.IconURL = absoluteLogoURL
  56. }
  57. }
  58. for _, atomEntry := range a.atomFeed.Entries {
  59. entry := model.NewEntry()
  60. // Populate the entry URL.
  61. entry.URL = atomEntry.Links.OriginalLink()
  62. if entry.URL != "" {
  63. if absoluteEntryURL, err := urllib.AbsoluteURL(feed.SiteURL, entry.URL); err == nil {
  64. entry.URL = absoluteEntryURL
  65. }
  66. }
  67. // Populate the entry content.
  68. entry.Content = atomEntry.Content.Body()
  69. if entry.Content == "" {
  70. entry.Content = atomEntry.Summary.Body()
  71. }
  72. if entry.Content == "" {
  73. entry.Content = atomEntry.FirstMediaDescription()
  74. }
  75. // Populate the entry title.
  76. entry.Title = atomEntry.Title.Title()
  77. if entry.Title == "" {
  78. entry.Title = sanitizer.TruncateHTML(entry.Content, 100)
  79. }
  80. if entry.Title == "" {
  81. entry.Title = entry.URL
  82. }
  83. // Populate the entry author.
  84. authors := atomEntry.Authors.PersonNames()
  85. if len(authors) == 0 {
  86. authors = append(authors, a.atomFeed.Authors.PersonNames()...)
  87. }
  88. authors = slices.Compact(authors)
  89. sort.Strings(authors)
  90. entry.Author = strings.Join(authors, ", ")
  91. // Populate the entry date.
  92. for _, value := range []string{atomEntry.Published, atomEntry.Updated} {
  93. if value != "" {
  94. if parsedDate, err := date.Parse(value); err != nil {
  95. slog.Debug("Unable to parse date from Atom 1.0 feed",
  96. slog.String("date", value),
  97. slog.String("url", entry.URL),
  98. slog.Any("error", err),
  99. )
  100. } else {
  101. entry.Date = parsedDate
  102. break
  103. }
  104. }
  105. }
  106. if entry.Date.IsZero() {
  107. entry.Date = time.Now()
  108. }
  109. // Populate categories.
  110. categories := atomEntry.Categories.CategoryNames()
  111. if len(categories) == 0 {
  112. categories = append(categories, a.atomFeed.Categories.CategoryNames()...)
  113. }
  114. if len(categories) > 0 {
  115. categories = slices.Compact(categories)
  116. sort.Strings(categories)
  117. entry.Tags = categories
  118. }
  119. // Populate the commentsURL if defined.
  120. // See https://tools.ietf.org/html/rfc4685#section-4
  121. // If the type attribute of the atom:link is omitted, its value is assumed to be "application/atom+xml".
  122. // We accept only HTML or XHTML documents for now since the intention is to have the same behavior as RSS.
  123. commentsURL := atomEntry.Links.firstLinkWithRelationAndType("replies", "text/html", "application/xhtml+xml")
  124. if urllib.IsAbsoluteURL(commentsURL) {
  125. entry.CommentsURL = commentsURL
  126. }
  127. // Generate the entry hash.
  128. for _, value := range []string{atomEntry.ID, atomEntry.Links.OriginalLink()} {
  129. if value != "" {
  130. entry.Hash = crypto.Hash(value)
  131. break
  132. }
  133. }
  134. // Populate the entry enclosures.
  135. uniqueEnclosuresMap := make(map[string]bool)
  136. for _, mediaThumbnail := range atomEntry.AllMediaThumbnails() {
  137. if _, found := uniqueEnclosuresMap[mediaThumbnail.URL]; !found {
  138. uniqueEnclosuresMap[mediaThumbnail.URL] = true
  139. entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
  140. URL: mediaThumbnail.URL,
  141. MimeType: mediaThumbnail.MimeType(),
  142. Size: mediaThumbnail.Size(),
  143. })
  144. }
  145. }
  146. for _, link := range atomEntry.Links {
  147. if strings.EqualFold(link.Rel, "enclosure") {
  148. if link.Href == "" {
  149. continue
  150. }
  151. if _, found := uniqueEnclosuresMap[link.Href]; !found {
  152. uniqueEnclosuresMap[link.Href] = true
  153. length, _ := strconv.ParseInt(link.Length, 10, 0)
  154. entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
  155. URL: link.Href,
  156. MimeType: link.Type,
  157. Size: length,
  158. })
  159. }
  160. }
  161. }
  162. for _, mediaContent := range atomEntry.AllMediaContents() {
  163. if _, found := uniqueEnclosuresMap[mediaContent.URL]; !found {
  164. uniqueEnclosuresMap[mediaContent.URL] = true
  165. entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
  166. URL: mediaContent.URL,
  167. MimeType: mediaContent.MimeType(),
  168. Size: mediaContent.Size(),
  169. })
  170. }
  171. }
  172. for _, mediaPeerLink := range atomEntry.AllMediaPeerLinks() {
  173. if _, found := uniqueEnclosuresMap[mediaPeerLink.URL]; !found {
  174. uniqueEnclosuresMap[mediaPeerLink.URL] = true
  175. entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
  176. URL: mediaPeerLink.URL,
  177. MimeType: mediaPeerLink.MimeType(),
  178. Size: mediaPeerLink.Size(),
  179. })
  180. }
  181. }
  182. feed.Entries = append(feed.Entries, entry)
  183. }
  184. return feed
  185. }