atom_10_adapter.go 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  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. "strconv"
  7. "strings"
  8. "time"
  9. "miniflux.app/v2/internal/crypto"
  10. "miniflux.app/v2/internal/model"
  11. "miniflux.app/v2/internal/reader/date"
  12. "miniflux.app/v2/internal/reader/language"
  13. "miniflux.app/v2/internal/reader/sanitizer"
  14. "miniflux.app/v2/internal/urllib"
  15. )
  16. type atom10Adapter struct {
  17. atomFeed *atom10Feed
  18. }
  19. func (a *atom10Adapter) buildFeed(baseURL string) *model.Feed {
  20. feed := &model.Feed{
  21. FeedURL: baseURL,
  22. SiteURL: baseURL,
  23. }
  24. // Populate the feed URL.
  25. feedURL := a.atomFeed.Links.firstLinkWithRelation("self")
  26. if feedURL != "" {
  27. if absoluteFeedURL, err := urllib.ResolveToAbsoluteURL(baseURL, feedURL); err == nil {
  28. feed.FeedURL = absoluteFeedURL
  29. }
  30. }
  31. // Populate the site URL.
  32. siteURL := a.atomFeed.Links.originalLink()
  33. if siteURL != "" {
  34. if absoluteSiteURL, err := urllib.ResolveToAbsoluteURL(baseURL, siteURL); err == nil {
  35. feed.SiteURL = absoluteSiteURL
  36. }
  37. }
  38. // Populate the feed title.
  39. feed.Title = a.atomFeed.Title.body()
  40. if feed.Title == "" {
  41. feed.Title = feed.SiteURL
  42. }
  43. // Populate the feed description.
  44. feed.Description = a.atomFeed.Subtitle.body()
  45. feed.Language = language.Normalize(a.atomFeed.Language)
  46. // Populate the feed icon.
  47. for _, value := range []string{a.atomFeed.Icon, a.atomFeed.Logo} {
  48. if value = strings.TrimSpace(value); value == "" {
  49. continue
  50. }
  51. if iconURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, value); err == nil {
  52. feed.IconURL = iconURL
  53. break
  54. }
  55. }
  56. feed.Entries = a.populateEntries(feed.SiteURL)
  57. return feed
  58. }
  59. func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
  60. entries := make(model.Entries, 0, len(a.atomFeed.Entries))
  61. for _, atomEntry := range a.atomFeed.Entries {
  62. entry := model.NewEntry()
  63. // Populate the entry URL.
  64. entry.URL = atomEntry.Links.originalLink()
  65. if entry.URL != "" {
  66. if absoluteEntryURL, err := urllib.ResolveToAbsoluteURL(siteURL, entry.URL); err == nil {
  67. entry.URL = absoluteEntryURL
  68. }
  69. }
  70. // If the entry has no links, attempt to use its ID as a URL
  71. // and if that fails, use the site URL.
  72. if entry.URL == "" {
  73. if urllib.IsAbsoluteURL(atomEntry.ID) {
  74. entry.URL = atomEntry.ID
  75. } else {
  76. entry.URL = siteURL
  77. }
  78. }
  79. // Populate the entry content.
  80. entry.Content = atomEntry.Content.body()
  81. if entry.Content == "" {
  82. entry.Content = atomEntry.Summary.body()
  83. if entry.Content == "" {
  84. entry.Content = atomEntry.FirstMediaDescription()
  85. }
  86. }
  87. // Populate the entry title.
  88. entry.Title = atomEntry.Title.title()
  89. if entry.Title == "" {
  90. entry.Title = sanitizer.TruncateHTML(entry.Content, 100)
  91. if entry.Title == "" {
  92. entry.Title = entry.URL
  93. }
  94. }
  95. // Populate the entry language. xml:lang applies to the whole
  96. // subtree it is declared on, so an entry without its own
  97. // xml:lang inherits the feed-level value.
  98. entry.Language = language.Normalize(atomEntry.Language)
  99. if entry.Language == "" {
  100. entry.Language = language.Normalize(a.atomFeed.Language)
  101. }
  102. // Populate the entry author.
  103. authors := atomEntry.Authors.personNames()
  104. if len(authors) == 0 {
  105. authors = a.atomFeed.Authors.personNames()
  106. }
  107. entry.Author = strings.Join(authors, ", ")
  108. // Populate the entry date.
  109. for _, value := range []string{atomEntry.Published, atomEntry.Updated} {
  110. if value = strings.TrimSpace(value); value == "" {
  111. continue
  112. }
  113. parsedDate, err := date.Parse(value)
  114. if err != nil {
  115. slog.Debug("Unable to parse date from Atom 1.0 feed",
  116. slog.String("date", value),
  117. slog.String("url", entry.URL),
  118. slog.Any("error", err),
  119. )
  120. continue
  121. }
  122. entry.Date = parsedDate
  123. break
  124. }
  125. if entry.Date.IsZero() {
  126. entry.Date = time.Now()
  127. }
  128. // Populate categories.
  129. entry.Tags = atomEntry.Categories.CategoryNames()
  130. if len(entry.Tags) == 0 {
  131. entry.Tags = a.atomFeed.Categories.CategoryNames()
  132. }
  133. // Populate the commentsURL if defined.
  134. // See https://tools.ietf.org/html/rfc4685#section-4
  135. // If the type attribute of the atom:link is omitted, its value is assumed to be "application/atom+xml".
  136. // We accept only HTML or XHTML documents for now since the intention is to have the same behavior as RSS.
  137. commentsURL := atomEntry.Links.firstLinkWithRelationAndType("replies", "text/html", "application/xhtml+xml")
  138. if urllib.IsAbsoluteURL(commentsURL) {
  139. entry.CommentsURL = commentsURL
  140. }
  141. // Generate the entry hash.
  142. for _, value := range []string{atomEntry.ID, atomEntry.Links.originalLink()} {
  143. if value != "" {
  144. entry.Hash = crypto.SHA256(value)
  145. break
  146. }
  147. }
  148. // Populate the entry enclosures.
  149. uniqueEnclosuresMap := make(map[string]bool)
  150. for _, mediaThumbnail := range atomEntry.AllMediaThumbnails() {
  151. mediaURL := strings.TrimSpace(mediaThumbnail.URL)
  152. if mediaURL == "" {
  153. continue
  154. }
  155. if _, found := uniqueEnclosuresMap[mediaURL]; found {
  156. continue
  157. }
  158. mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL)
  159. if err != nil {
  160. slog.Debug("Unable to build absolute URL for media thumbnail",
  161. slog.String("url", mediaThumbnail.URL),
  162. slog.String("site_url", siteURL),
  163. slog.Any("error", err),
  164. )
  165. continue
  166. }
  167. uniqueEnclosuresMap[mediaAbsoluteURL] = true
  168. entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
  169. URL: mediaAbsoluteURL,
  170. MimeType: mediaThumbnail.MimeType(),
  171. Size: mediaThumbnail.Size(),
  172. })
  173. }
  174. for _, link := range atomEntry.Links.findAllLinksWithRelation("enclosure") {
  175. absoluteEnclosureURL, err := urllib.ResolveToAbsoluteURL(siteURL, link.Href)
  176. if err != nil {
  177. slog.Debug("Unable to resolve absolute URL for enclosure",
  178. slog.String("enclosure_url", link.Href),
  179. slog.String("entry_url", entry.URL),
  180. slog.Any("error", err),
  181. )
  182. continue
  183. }
  184. if _, found := uniqueEnclosuresMap[absoluteEnclosureURL]; found {
  185. continue
  186. }
  187. uniqueEnclosuresMap[absoluteEnclosureURL] = true
  188. length, _ := strconv.ParseInt(link.Length, 10, 0)
  189. entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
  190. URL: absoluteEnclosureURL,
  191. MimeType: link.Type,
  192. Size: length,
  193. })
  194. }
  195. for _, mediaContent := range atomEntry.AllMediaContents() {
  196. mediaURL := strings.TrimSpace(mediaContent.URL)
  197. if mediaURL == "" {
  198. continue
  199. }
  200. mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL)
  201. if err != nil {
  202. slog.Debug("Unable to build absolute URL for media content",
  203. slog.String("url", mediaContent.URL),
  204. slog.String("site_url", siteURL),
  205. slog.Any("error", err),
  206. )
  207. continue
  208. }
  209. if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; found {
  210. continue
  211. }
  212. uniqueEnclosuresMap[mediaAbsoluteURL] = true
  213. entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
  214. URL: mediaAbsoluteURL,
  215. MimeType: mediaContent.MimeType(),
  216. Size: mediaContent.Size(),
  217. })
  218. }
  219. for _, mediaPeerLink := range atomEntry.AllMediaPeerLinks() {
  220. mediaURL := strings.TrimSpace(mediaPeerLink.URL)
  221. if mediaURL == "" {
  222. continue
  223. }
  224. mediaAbsoluteURL, err := urllib.ResolveToAbsoluteURL(siteURL, mediaURL)
  225. if err != nil {
  226. slog.Debug("Unable to build absolute URL for media peer link",
  227. slog.String("url", mediaPeerLink.URL),
  228. slog.String("site_url", siteURL),
  229. slog.Any("error", err),
  230. )
  231. continue
  232. }
  233. if _, found := uniqueEnclosuresMap[mediaAbsoluteURL]; found {
  234. continue
  235. }
  236. uniqueEnclosuresMap[mediaAbsoluteURL] = true
  237. entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
  238. URL: mediaAbsoluteURL,
  239. MimeType: mediaPeerLink.MimeType(),
  240. Size: mediaPeerLink.Size(),
  241. })
  242. }
  243. entries = append(entries, entry)
  244. }
  245. return entries
  246. }