html2rss 0.32.4 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/html2rss/article/deduplicator.rb +5 -2
  4. data/lib/html2rss/article/enclosure.rb +185 -25
  5. data/lib/html2rss/article/title.rb +203 -0
  6. data/lib/html2rss/article.rb +79 -13
  7. data/lib/html2rss/auto_source/README.md +16 -8
  8. data/lib/html2rss/auto_source/cleanup.rb +127 -53
  9. data/lib/html2rss/auto_source/corroboration.rb +167 -0
  10. data/lib/html2rss/auto_source/page_links/anchor_claims.rb +82 -0
  11. data/lib/html2rss/auto_source/page_links/card_units.rb +77 -0
  12. data/lib/html2rss/auto_source/page_links/label_binding.rb +79 -0
  13. data/lib/html2rss/auto_source/page_links/sponsored_cards.rb +147 -0
  14. data/lib/html2rss/auto_source/page_links.rb +164 -0
  15. data/lib/html2rss/auto_source/scraper/html.rb +11 -45
  16. data/lib/html2rss/auto_source/scraper/json_document.rb +3 -6
  17. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +202 -125
  18. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +27 -38
  19. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +123 -123
  20. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +27 -35
  21. data/lib/html2rss/auto_source/scraper/json_state.rb +4 -9
  22. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +27 -5
  23. data/lib/html2rss/auto_source/scraper/microdata.rb +233 -248
  24. data/lib/html2rss/auto_source/scraper/microformats2.rb +10 -4
  25. data/lib/html2rss/auto_source/scraper/native_feed.rb +28 -18
  26. data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +7 -2
  27. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
  28. data/lib/html2rss/auto_source/scraper/schema/thing.rb +140 -22
  29. data/lib/html2rss/auto_source/scraper/schema.rb +52 -8
  30. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
  31. data/lib/html2rss/auto_source/scraper/semantic_html.rb +12 -20
  32. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +1 -4
  33. data/lib/html2rss/auto_source/scraper/sitemap.rb +7 -14
  34. data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -2
  35. data/lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb +1 -3
  36. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +7 -7
  37. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +2 -4
  38. data/lib/html2rss/auto_source/scraper.rb +10 -13
  39. data/lib/html2rss/auto_source/segment.rb +19 -5
  40. data/lib/html2rss/auto_source/segmenter/cluster.rb +18 -22
  41. data/lib/html2rss/auto_source/segmenter/list.rb +35 -59
  42. data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
  43. data/lib/html2rss/auto_source/segmenter/semantic.rb +23 -19
  44. data/lib/html2rss/auto_source/segmenter.rb +4 -8
  45. data/lib/html2rss/auto_source.rb +81 -26
  46. data/lib/html2rss/batch.rb +24 -32
  47. data/lib/html2rss/capture/README.md +6 -3
  48. data/lib/html2rss/capture.rb +51 -59
  49. data/lib/html2rss/channel.rb +30 -12
  50. data/lib/html2rss/cli/render.rb +97 -96
  51. data/lib/html2rss/cli/validate.rb +2 -4
  52. data/lib/html2rss/cli.rb +55 -38
  53. data/lib/html2rss/config/auto_source_contract.rb +52 -45
  54. data/lib/html2rss/config/issue_mapper.rb +4 -1
  55. data/lib/html2rss/config/request_controls.rb +16 -5
  56. data/lib/html2rss/config/schema.rb +23 -15
  57. data/lib/html2rss/config/selectors_validator.rb +3 -2
  58. data/lib/html2rss/config/validation_issue.rb +4 -1
  59. data/lib/html2rss/config/validator.rb +14 -3
  60. data/lib/html2rss/config.rb +54 -28
  61. data/lib/html2rss/contract_error.rb +9 -0
  62. data/lib/html2rss/defaults.rb +25 -38
  63. data/lib/html2rss/doctor/botasaurus.rb +18 -22
  64. data/lib/html2rss/error.rb +17 -3
  65. data/lib/html2rss/error_kind.rb +39 -0
  66. data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
  67. data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
  68. data/lib/html2rss/feed_builder/json_feed.rb +2 -2
  69. data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
  70. data/lib/html2rss/feed_builder/rss.rb +50 -7
  71. data/lib/html2rss/feed_builder.rb +2 -1
  72. data/lib/html2rss/feed_pipeline/README.md +1 -1
  73. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -34
  74. data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
  75. data/lib/html2rss/feed_pipeline.rb +40 -27
  76. data/lib/html2rss/feed_resolution/README.md +1 -1
  77. data/lib/html2rss/feed_resolution/candidate_generator.rb +6 -8
  78. data/lib/html2rss/feed_resolution/options.rb +3 -7
  79. data/lib/html2rss/feed_resolution/policy.rb +29 -33
  80. data/lib/html2rss/feed_resolution/probe.rb +6 -4
  81. data/lib/html2rss/feed_resolution/scorer.rb +29 -29
  82. data/lib/html2rss/feed_resolution.rb +16 -21
  83. data/lib/html2rss/feed_result.rb +11 -6
  84. data/lib/html2rss/hash_util.rb +91 -90
  85. data/lib/html2rss/html/article_extractor/category_extractor.rb +7 -10
  86. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  87. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -33
  88. data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
  89. data/lib/html2rss/html/article_extractor/id_generator.rb +7 -10
  90. data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
  91. data/lib/html2rss/html/article_extractor.rb +19 -47
  92. data/lib/html2rss/html/article_rules/category.rb +3 -3
  93. data/lib/html2rss/html/article_rules/description.rb +6 -15
  94. data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
  95. data/lib/html2rss/html/article_rules/image.rb +30 -16
  96. data/lib/html2rss/html/feed_link.rb +7 -9
  97. data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
  98. data/lib/html2rss/html/navigator.rb +39 -9
  99. data/lib/html2rss/html/probe.rb +55 -51
  100. data/lib/html2rss/html/rendering/description_builder.rb +46 -32
  101. data/lib/html2rss/html/rendering.rb +2 -9
  102. data/lib/html2rss/html/sst_article_extractor.rb +55 -145
  103. data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
  104. data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
  105. data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
  106. data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
  107. data/lib/html2rss/html/title_pieces.rb +169 -0
  108. data/lib/html2rss/html/visibility.rb +56 -0
  109. data/lib/html2rss/html.rb +2 -0
  110. data/lib/html2rss/link_destination/destination_facts.rb +17 -1
  111. data/lib/html2rss/link_destination/noise_policy.rb +11 -10
  112. data/lib/html2rss/link_destination/path_classifier.rb +111 -42
  113. data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
  114. data/lib/html2rss/link_destination/sponsorship.rb +184 -0
  115. data/lib/html2rss/link_destination/text_classifier.rb +104 -8
  116. data/lib/html2rss/mcp/README.md +13 -1
  117. data/lib/html2rss/mcp/config_argument.rb +14 -4
  118. data/lib/html2rss/mcp/contract.rb +20 -9
  119. data/lib/html2rss/mcp/outcome.rb +18 -17
  120. data/lib/html2rss/mcp/runtime.rb +53 -23
  121. data/lib/html2rss/mcp/server/tools.rb +9 -9
  122. data/lib/html2rss/mcp/server.rb +13 -4
  123. data/lib/html2rss/option.rb +48 -0
  124. data/lib/html2rss/page_recon/README.md +1 -1
  125. data/lib/html2rss/page_recon/diagnostics.rb +9 -9
  126. data/lib/html2rss/page_recon.rb +0 -4
  127. data/lib/html2rss/recon.rb +19 -23
  128. data/lib/html2rss/request_service/blocked_surface.rb +5 -8
  129. data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
  130. data/lib/html2rss/request_service/botasaurus_strategy.rb +44 -12
  131. data/lib/html2rss/request_service/budget.rb +3 -24
  132. data/lib/html2rss/request_service/compressed_body.rb +36 -48
  133. data/lib/html2rss/request_service/context.rb +16 -17
  134. data/lib/html2rss/request_service/cookie_jar.rb +89 -0
  135. data/lib/html2rss/request_service/cookie_store.rb +79 -0
  136. data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
  137. data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
  138. data/lib/html2rss/request_service/local_file_strategy.rb +2 -3
  139. data/lib/html2rss/request_service/network_guard.rb +3 -2
  140. data/lib/html2rss/request_service/policy.rb +55 -36
  141. data/lib/html2rss/request_service/replay_breaker.rb +56 -0
  142. data/lib/html2rss/request_service/response.rb +33 -19
  143. data/lib/html2rss/request_service/response_cache.rb +109 -0
  144. data/lib/html2rss/request_service/response_caching.rb +95 -0
  145. data/lib/html2rss/request_service/strategy.rb +5 -11
  146. data/lib/html2rss/request_service.rb +71 -12
  147. data/lib/html2rss/request_session/pager/base.rb +3 -10
  148. data/lib/html2rss/request_session/pager/custom_selector.rb +4 -4
  149. data/lib/html2rss/request_session/pager/offset.rb +1 -7
  150. data/lib/html2rss/request_session/pager/rel_next.rb +1 -1
  151. data/lib/html2rss/request_session/pager/url_template.rb +1 -7
  152. data/lib/html2rss/request_session/pager.rb +1 -1
  153. data/lib/html2rss/request_session.rb +6 -2
  154. data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
  155. data/lib/html2rss/scoring/container_assessor.rb +12 -13
  156. data/lib/html2rss/scoring/engine.rb +10 -38
  157. data/lib/html2rss/scoring/link_resolver.rb +2 -7
  158. data/lib/html2rss/scoring/observation.rb +2 -2
  159. data/lib/html2rss/selector_candidates.rb +19 -8
  160. data/lib/html2rss/selectors/README.md +1 -1
  161. data/lib/html2rss/selectors/extractors/href.rb +2 -4
  162. data/lib/html2rss/selectors/json_xml.rb +33 -34
  163. data/lib/html2rss/selectors/option_spec.rb +4 -1
  164. data/lib/html2rss/selectors/post_processors/base.rb +0 -8
  165. data/lib/html2rss/selectors/post_processors/gsub.rb +9 -10
  166. data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
  167. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
  168. data/lib/html2rss/selectors/post_processors/parse_time.rb +5 -6
  169. data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
  170. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
  171. data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
  172. data/lib/html2rss/selectors/schema_export.rb +60 -60
  173. data/lib/html2rss/selectors.rb +67 -30
  174. data/lib/html2rss/sst/attrs.rb +2 -2
  175. data/lib/html2rss/sst/document.rb +3 -3
  176. data/lib/html2rss/sst/index.rb +30 -11
  177. data/lib/html2rss/sst/node.rb +42 -29
  178. data/lib/html2rss/sst/normalizer.rb +47 -34
  179. data/lib/html2rss/sst/tags.rb +113 -2
  180. data/lib/html2rss/sst/text.rb +94 -58
  181. data/lib/html2rss/sst.rb +2 -0
  182. data/lib/html2rss/status.rb +27 -31
  183. data/lib/html2rss/surface_category.rb +1 -1
  184. data/lib/html2rss/syndication/README.md +12 -6
  185. data/lib/html2rss/syndication/alternate_links.rb +39 -0
  186. data/lib/html2rss/syndication/channel_facts.rb +87 -0
  187. data/lib/html2rss/syndication/discovery.rb +171 -187
  188. data/lib/html2rss/syndication/feed.rb +20 -0
  189. data/lib/html2rss/syndication/item_media.rb +103 -0
  190. data/lib/html2rss/syndication/parser.rb +117 -116
  191. data/lib/html2rss/test/enhance_audit.rb +15 -17
  192. data/lib/html2rss/test/item_sample.rb +43 -0
  193. data/lib/html2rss/test/policy.rb +9 -11
  194. data/lib/html2rss/test.rb +66 -57
  195. data/lib/html2rss/url.rb +163 -62
  196. data/lib/html2rss/version.rb +1 -1
  197. data/lib/html2rss/word_count.rb +53 -0
  198. data/lib/html2rss.rb +19 -9
  199. data/schema/html2rss-config.schema.json +56 -41
  200. metadata +33 -4
  201. data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
  202. data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a53d8374b6b4ce7551c9f1bffd0e14960995f7df7380554f1292e0a70317cf58
4
- data.tar.gz: 90e91d4275c03c120924cca66230a7aecb3606f2f50c6224d019c138cae244e2
3
+ metadata.gz: 06fbd552569edb4a645f262cd55a156e93e658421a497865ddae2e8e8b62ced9
4
+ data.tar.gz: d23cc7119baaa02d2994f169386a2dc12881032dc3c44975051b634a2fbbaf87
5
5
  SHA512:
6
- metadata.gz: d2543d4431b2790c89c19ab207d0cdbbc7416493ea1184347746bc26e21068415d68f025e0cc253082224ca5191a3984163e3e48ef3e727cb3b78c19cddd3d64
7
- data.tar.gz: ac588fece229b58ab2ea4de91bb467fdbfaf199f43febfd8be6b39d817fc9b9b2867c21cb472949e886ad4dd50b436d9c5c149c0976a87b8cd67d09a6a62f9f6
6
+ metadata.gz: 0d92e97cf1eba6d0d05d1454340bcee34fc7a089641baeb446d9c0984d56b9e1827ccb82469bb28e592c5795b489de171d998da5bf94079600fecc5a29d6b2ee
7
+ data.tar.gz: 77d55816b01d3efb271bcb203fe964a7177df68a04dc87de3b243acc6be6b6c9ad5cb0d9fd4c9c2a8137767828c97a3a04da500f14d076364a9df133b85d1781
data/README.md CHANGED
@@ -28,8 +28,8 @@ Cloud development: [Open in GitHub Codespaces](https://github.com/codespaces/new
28
28
  1. **Config** — loads and validates configuration (YAML/hash); schema via `html2rss schema` / `schema/html2rss-config.schema.json`
29
29
  2. **RequestService** — fetches pages (`default` (HTTPX), `botasaurus`, or `local_file`)
30
30
  3. **Selectors** — extracts content via CSS selectors with extractors/post-processors
31
- 4. **AutoSource** — auto-detects content (Schema.org, JSON state, semantic HTML, structural patterns)
32
- 5. **FeedBuilder** — assembles Article objects and renders feeds (RSS 2.0 / JSON Feed 1.1)
31
+ 4. **AutoSource** — auto-detects content (Schema.org, JSON state, semantic HTML, structural patterns); a card's `<audio>`/`<video>` file becomes its enclosure
32
+ 5. **FeedBuilder** — assembles Article objects and renders feeds (RSS 2.0 / JSON Feed 1.1), with podcast tags for audio/video feeds
33
33
 
34
34
  ```text
35
35
  Config -> Request -> Extraction -> Processing -> Building -> Output
@@ -65,6 +65,8 @@ html2rss scrape https://example.com/news --format jsonfeed --explain
65
65
  # Off-domain item URLs are dropped by default. --keep-different-domain opts in
66
66
  # and lets the remote source control published links to unrelated domains.
67
67
  html2rss scrape https://example.com/app-list.json --keep-different-domain
68
+ # Items the page marks as sponsored are dropped by default. --keep-sponsored opts in.
69
+ html2rss scrape https://example.com/news --keep-sponsored
68
70
 
69
71
  # Durable config workflow
70
72
  html2rss capture https://example.com/news --write feed.yml
@@ -11,11 +11,14 @@ module Html2rss
11
11
  # description metadata. If none of these identifiers are available it
12
12
  # defaults to the article object's hash to preserve the original entry.
13
13
  class Deduplicator
14
+ # Raised when the deduplicator is built without an article list.
15
+ class InvalidError < Html2rss::ContractError; end
16
+
14
17
  ##
15
18
  # @param articles [Array<Html2rss::Article>]
16
- # @raise [ArgumentError] if articles are not provided
19
+ # @raise [InvalidError] if articles are not provided
17
20
  def initialize(articles)
18
- raise ArgumentError, 'articles must be provided' unless articles
21
+ raise InvalidError, 'articles must be provided' unless articles
19
22
 
20
23
  @articles = articles
21
24
  end
@@ -6,43 +6,203 @@ module Html2rss
6
6
  class Article
7
7
  ##
8
8
  # Represents an enclosure attached to an article (RSS / JSON Feed media).
9
+ #
10
+ # The one owner of media-file facts: URL, MIME type, byte length and playback duration,
11
+ # the media-kind predicates feed builders choose by, and the parsers that turn
12
+ # publisher-declared size and duration strings into those facts.
9
13
  class Enclosure
10
- ##
11
- # Guesses the content type based on the file extension of the URL.
12
- #
13
- # @param url [Html2rss::Url]
14
- # @param default [String] default content type
15
- # @return [String] guessed content type, or default
16
- def self.guess_content_type_from_url(url, default: 'application/octet-stream')
17
- return default unless url
18
-
19
- path = url.path
20
- ext = File.extname(path)
21
- ext = ext[1..] if ext.start_with?('.')
22
-
23
- content_type = MIME::Types.type_for(ext)
24
- content_type.first&.to_s || 'application/octet-stream'
14
+ # Raised when an enclosure has no absolute http(s) URL or declares an unknown kind.
15
+ class InvalidError < Html2rss::ContractError; end
16
+
17
+ # A byte size: a number with an optional unit (+12345+, +850 KB+, +12.3 MB+, +1.5GiB+). The whitespace
18
+ # runs are possessive, so a long run of spaces never backtracks.
19
+ SIZE = /\A\s*+(\d+(?:\.\d+)?)\s*+([kmgt]i?b|b|bytes?)?\s*+\z/i
20
+ # Bytes per size unit; SI prefixes count in thousands, binary (+KiB+) in 1024s.
21
+ SIZE_UNITS = {
22
+ 'b' => 1, 'byte' => 1, 'bytes' => 1,
23
+ 'kb' => 1000, 'mb' => 1000**2, 'gb' => 1000**3, 'tb' => 1000**4,
24
+ 'kib' => 1024, 'mib' => 1024**2, 'gib' => 1024**3, 'tib' => 1024**4
25
+ }.freeze
26
+ # An ISO 8601 duration of days and time parts (+PT1H2M3S+, +PT45M+, +P1DT2H+).
27
+ DURATION = /\AP(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?\z/
28
+ # A clock duration (+itunes:duration+, Media RSS): +HH:MM:SS+, +MM:SS+ or plain seconds.
29
+ CLOCK = /\A(?:(?:(\d+):)?(\d+):)?(\d+(?:\.\d+)?)\z/
30
+ # Types of web pages (embed players, article pages), never a media file: HTML, XHTML and the
31
+ # types MIME::Types guesses from server-page script extensions (+.php+, +.asp+, +.aspx+, +.jsp+,
32
+ # +.cgi+, +.cfm+). Other +text/*+ files (+.txt+, +.csv+, +.ics+, +.vtt+) are downloads.
33
+ PAGE_TYPES = %w[
34
+ text/html application/xhtml+xml text/x-php application/x-httpd-php text/asp text/aspdotnet text/x-jsp
35
+ text/x-cgi text/x-coldfusion
36
+ ].freeze
37
+ # Types of stream playlists (HLS, DASH) and Flash players: played through a page or player, never
38
+ # downloaded as a file.
39
+ STREAM_TYPES = %w[
40
+ application/vnd.apple.mpegurl application/x-mpegurl application/dash+xml application/x-shockwave-flash
41
+ ].freeze
42
+ # The type of a URL that declares none and has no known extension.
43
+ UNTYPED = 'application/octet-stream'
44
+ # Playable audio/video types by file extension. MIME::Types lists these extensions under
45
+ # several types and puts a non-playable or codec-level one first (+.mp4+ as +application/mp4+,
46
+ # +.ogg+ as +audio/vorbis+, +.m4v+ without +video/mp4+), so media extensions are looked up here first.
47
+ # HLS playlists (+.m3u8+) are no media file: MIME::Types types them +application/vnd.apple.mpegurl+.
48
+ MEDIA_TYPES = {
49
+ 'mp4' => 'video/mp4', 'm4v' => 'video/mp4', 'ogv' => 'video/ogg', 'webm' => 'video/webm',
50
+ 'm4a' => 'audio/mp4', 'mp3' => 'audio/mpeg', 'ogg' => 'audio/ogg', 'oga' => 'audio/ogg',
51
+ 'opus' => 'audio/opus', 'aac' => 'audio/aac', 'wav' => 'audio/wav', 'flac' => 'audio/flac'
52
+ }.freeze
53
+ # The type of a file whose source proves its kind (an audio or video player, a Schema.org media object,
54
+ # a Media RSS +medium+) while its URL names no type (no or an unknown extension, or a server page): the
55
+ # most widely used format of that kind, since an RSS enclosure needs a concrete type and +audio/*+ is none.
56
+ KIND_TYPES = { audio: 'audio/mpeg', video: 'video/mp4', image: 'image/jpeg' }.freeze
57
+
58
+ class << self
59
+ ##
60
+ # Guesses the content type based on the file extension of the URL: a media extension
61
+ # ({MEDIA_TYPES}) gives its audio or video type, any other the first MIME::Types match.
62
+ #
63
+ # @param url [Html2rss::Url]
64
+ # @param default [String] default content type
65
+ # @return [String] guessed content type, or default
66
+ def guess_content_type_from_url(url, default: UNTYPED)
67
+ return default unless url
68
+
69
+ path = url.path
70
+ MEDIA_TYPES[File.extname(path).delete_prefix('.').downcase] ||
71
+ MIME::Types.type_for(path).first&.content_type || default
72
+ end
73
+
74
+ ##
75
+ # Parses a declared file size (Schema.org +contentSize+ and alike) into bytes.
76
+ #
77
+ # @param value [String, Integer, nil] e.g. +"12.3 MB"+, +"850 KB"+, +"12345"+
78
+ # @return [Integer, nil] positive byte count, or nil when unparseable
79
+ def parse_size(value)
80
+ return unless (match = SIZE.match(value.to_s))
81
+
82
+ bytes = (Float(match[1]) * SIZE_UNITS.fetch(match[2].to_s.downcase, 1)).round
83
+ bytes if bytes.positive?
84
+ end
85
+
86
+ ##
87
+ # Parses a declared playback duration into seconds: ISO 8601 (Schema.org +duration+) or a
88
+ # clock (+itunes:duration+, Media RSS +duration+).
89
+ #
90
+ # @param value [String, Integer, nil] e.g. +"PT1H2M3S"+, +"PT45M"+, +"1:02:03"+, +"45:00"+, +"2700"+
91
+ # @return [Integer, nil] positive seconds, or nil when unparseable
92
+ def parse_duration(value)
93
+ return unless (parts = duration_parts(value.to_s.strip.upcase))
94
+
95
+ days, hours, minutes, seconds = parts.map(&:to_f)
96
+ total = ((days * 86_400) + (hours * 3600) + (minutes * 60) + seconds).round
97
+ total if total.positive?
98
+ end
99
+
100
+ private
101
+
102
+ # @return [Array<String, nil>, nil] days, hours, minutes and seconds, or nil when no duration shape matches
103
+ def duration_parts(text)
104
+ DURATION.match(text)&.captures || CLOCK.match(text)&.captures&.unshift(nil)
105
+ end
25
106
  end
26
107
 
27
- # @param url [Html2rss::Url] absolute enclosure URL
108
+ # @param url [Html2rss::Url] absolute http(s) enclosure URL ({Url#web?}); sources resolve declared references
109
+ # with {Url.resolve_web} and drop the rest, so no feed format ships a +javascript:+, +data:+ or +ftp:+ file
28
110
  # @param type [String, nil] optional enclosure MIME type
29
- # @param bytes_length [Integer] enclosure length in bytes
30
- def initialize(url:, type: nil, bytes_length: 0)
31
- raise ArgumentError, 'An Enclosure requires an absolute URL' if !url || !url.absolute?
111
+ # @param bytes_length [Integer, nil] enclosure length in bytes; nil when unknown
112
+ # @param duration [Integer, nil] playback length in seconds, kept only for an audio or video file ({#media?});
113
+ # nil (or not positive) when unknown
114
+ # @param kind [Symbol, nil] +:audio+, +:video+ or +:image+ when its source proves the file's kind
115
+ # ({KIND_TYPES}); nil when unknown
116
+ # @raise [InvalidError] when the URL is not an absolute http(s) URL or the kind is unknown
117
+ def initialize(url:, type: nil, bytes_length: nil, duration: nil, kind: nil)
118
+ raise InvalidError, 'An Enclosure requires an absolute http(s) URL' unless url&.web?
32
119
 
33
120
  @url = url
34
- @type = type
35
- @bytes_length = bytes_length
121
+ @bytes_length = bytes_length || 0
122
+ @type = resolved_type(type, kind_type(kind))
123
+ @mime = @type.downcase[/\A[^;\s]*/]
124
+ @duration = duration if duration.instance_of?(Integer) && duration.positive? && media?
36
125
  end
37
126
 
38
- # @return [String] explicit MIME type or one inferred from URL extension
39
- def type = @type || self.class.guess_content_type_from_url(url)
127
+ # @return [String] explicit MIME type, else one inferred from the URL extension; a file of a known kind whose
128
+ # URL names no media type takes that kind's type ({KIND_TYPES}), also when it is declared {UNTYPED}
129
+ attr_reader :type
40
130
 
41
- # @return [Integer] enclosure length in bytes
131
+ # @return [Integer] enclosure length in bytes; 0 when unknown
42
132
  attr_reader :bytes_length
43
133
 
44
- # @return [Html2rss::Url] absolute enclosure URL
134
+ # @return [Integer, nil] playback length in seconds of an audio or video file; nil for any other file
135
+ attr_reader :duration
136
+
137
+ # @return [Html2rss::Url] absolute http(s) enclosure URL
45
138
  attr_reader :url
139
+
140
+ # @return [Boolean] whether the file is audio
141
+ def audio? = mime.start_with?('audio/')
142
+
143
+ # @return [Boolean] whether the file is video
144
+ def video? = mime.start_with?('video/')
145
+
146
+ # @return [Boolean] whether the file is audio or video
147
+ def media? = audio? || video?
148
+
149
+ # @return [Boolean] whether the file is an image
150
+ def image? = mime.start_with?('image/')
151
+
152
+ # @return [Boolean] whether the URL is a web page ({PAGE_TYPES}: HTML, XHTML, a server page), such as an
153
+ # embed player
154
+ def page? = PAGE_TYPES.include?(mime)
155
+
156
+ # @return [Boolean] whether the URL path ends in a file extension (+.pdf+), unlike a watch page (+/watch/42+)
157
+ def file_extension? = !File.extname(url.path).empty?
158
+
159
+ # A watch or player page (+/watch/42+) declared as a file: a web page ({#page?}), or an untyped URL
160
+ # ({UNTYPED}: no declared type and no known file extension) with no file extension and no declared
161
+ # byte length. A declared length makes an extensionless URL a file (+/download/42+), since only a
162
+ # file has one.
163
+ #
164
+ # @return [Boolean] whether the URL is a watch or player page rather than a file
165
+ def player_page?
166
+ page? || (mime == UNTYPED && !file_extension? && bytes_length.zero?)
167
+ end
168
+
169
+ # A file a feed reader can download: no stream playlist or Flash player ({STREAM_TYPES}), and no watch
170
+ # or player page ({#player_page?}). {Article#enclosures} keeps only files, so no feed format ships the rest.
171
+ #
172
+ # @return [Boolean] whether the URL is a downloadable file
173
+ def file? = !(STREAM_TYPES.include?(mime) || player_page?)
174
+
175
+ # A downloadable document or archive (PDF, ZIP): a file ({#file?}) that is no media file or image.
176
+ #
177
+ # @return [Boolean] whether the URL is a downloadable document or archive
178
+ def document? = file? && !media? && !image?
179
+
180
+ private
181
+
182
+ # @return [String] the lowercase MIME type without parameters (+audio/ogg; codecs=opus+ is +audio/ogg+)
183
+ attr_reader :mime
184
+
185
+ # @param kind [Symbol, nil]
186
+ # @return [String, nil] the type a file of that kind takes when its URL names none ({KIND_TYPES})
187
+ # @raise [InvalidError] for a kind other than +:audio+, +:video+ or +:image+
188
+ def kind_type(kind)
189
+ return unless kind
190
+
191
+ KIND_TYPES.fetch(kind) { raise InvalidError, "Unknown enclosure kind: #{kind.inspect}" }
192
+ end
193
+
194
+ # The declared type, else the type guessed from the URL; a file of a known kind whose URL names no type
195
+ # (declared {UNTYPED}, or guessed untyped or a server page) takes that kind's type ({KIND_TYPES}) instead.
196
+ #
197
+ # @param declared [String, nil]
198
+ # @param kind_type [String, nil]
199
+ # @return [String]
200
+ def resolved_type(declared, kind_type)
201
+ return declared if declared && !(kind_type && declared.casecmp?(UNTYPED))
202
+
203
+ guessed = self.class.guess_content_type_from_url(url)
204
+ kind_type && (guessed == UNTYPED || PAGE_TYPES.include?(guessed)) ? kind_type : guessed
205
+ end
46
206
  end
47
207
  end
48
208
  end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class Article
5
+ ##
6
+ # Owns article title composition and the one title-noise vocabulary.
7
+ #
8
+ # Whitespace collapse ({.collapse}) is universal and also drops invisible characters ({INVISIBLE}).
9
+ # Composition ({.compose}) is opt-in per caller (see {Article#initialize}): configured selector titles
10
+ # and publisher feed titles stay verbatim.
11
+ #
12
+ # A composed title takes the form +Kicker: Headline+ (or just +Headline+, or the kicker alone when
13
+ # the headline is blank). Both parts are whitespace-collapsed and trimmed of stray separators
14
+ # ({SEPARATOR_MARK}). Only the kicker is noise-stripped by text: a leading clock time set off by a
15
+ # separator (+10:00 | Topic+), screen-reader field labels (+Section:+) and type labels with their
16
+ # marker (+Article type: Video+). The label and marker sets are small, closed and multilingual
17
+ # (en, de, fr, es, it, nl, pl, sv, ja), matching {LinkDestination::TextClassifier}.
18
+ #
19
+ # A Japanese headline may open with a bracket label (+【速報】+, +<動画>+): one naming a content type
20
+ # ({TYPE_MARKERS}) is removed, any other leading bracket text becomes the kicker ({LEADING_BRACKET}).
21
+ #
22
+ # Headline text is never noise-stripped: +20:15 Uhr: Neuer Krimi+ or +9:41 is the time+ is a real
23
+ # headline. Teaser noise in a headline is told apart by structure instead ({Html::TitlePieces}):
24
+ # date and time elements leave wherever they sit outside running text, elements that are only a
25
+ # clock or a label ({.headline_noise_piece?}) only before the first headline word, and kicker
26
+ # pieces are judged with {.noise_piece?}.
27
+ module Title
28
+ # Joins a kicker to its headline.
29
+ SEPARATOR = ': '
30
+ # Characters that render nothing inside a word: soft hyphen, zero-width space, word joiner and
31
+ # byte-order mark. Every title drops them, verbatim publisher titles included. Zero-width non-joiner
32
+ # and joiner (U+200C, U+200D) stay: they shape Persian, Urdu and Indic spelling and join emoji.
33
+ INVISIBLE = /[\u00AD\u200B\u2060\uFEFF]/
34
+ # Clock time of day (24h, or 12h with AM/PM). Description chip peeling reuses it. Case-sensitive:
35
+ # a spaced lowercase +am+ is the German preposition (+9:15 am Bahnhof+), so only +9:15am+ takes it.
36
+ CLOCK = /(?:[01]?\d|2[0-3]):[0-5]\d(?::[0-5]\d)?(?:\s*(?:AM|PM)|am|pm)?/
37
+ # A whole piece that is only a clock time, optionally with +Uhr+ or one timezone/offset token
38
+ # (IANA, GMT/UTC offset, or abbreviation). Description peels it as a chip suffix too.
39
+ CLOCK_PIECE = %r{
40
+ \A
41
+ #{CLOCK}
42
+ (?:\s+(?:[A-Za-z]+(?:/[A-Za-z_]+)+|(?:GMT|UTC)[+-]\d{1,2}(?::\d{2})?|[A-Za-z]{2,5}))?
43
+ \z
44
+ }ix
45
+ # A whole piece that is only a Japanese calendar date (+11月12日+), optionally with a year and a clock time
46
+ # (+11月12日 16:42+).
47
+ DATE_PIECE = /\A(?:\d{4}年)?\d{1,2}月\d{1,2}日(?:\s+#{CLOCK})?\z/
48
+ # Content-type markers (lowercase). Description drops them as whole leftover lines.
49
+ TYPE_MARKERS = Set[
50
+ 'news', 'news article', 'press release', 'video', 'audio', 'podcast', 'gallery', 'live', 'live blog',
51
+ 'full episode', 'bildergalerie', 'fotostrecke', 'liveticker', 'pressemitteilung', 'ganze folge',
52
+ 'vidéo', 'diaporama', 'en direct', 'communiqué de presse', 'épisode complet',
53
+ 'vídeo', 'galería', 'en directo', 'nota de prensa', 'episodio completo',
54
+ 'diretta', 'in diretta', 'diretta streaming', 'fotogallery', 'galleria', 'comunicato stampa',
55
+ 'puntata completa',
56
+ 'liveblog', 'fotogalerij', 'persbericht', 'volledige aflevering',
57
+ 'wideo', 'galeria', 'na żywo', 'relacja na żywo', 'komunikat prasowy', 'cały odcinek',
58
+ 'bildspel', 'direkt', 'direktsändning', 'liverapport', 'livesändning', 'pressmeddelande', 'hela avsnittet',
59
+ '速報', '詳報', '独自', '動画', '写真', '中継', '生中継', 'ライブ', 'プレスリリース'
60
+ ].freeze
61
+ # The markers as one alternation, longest first so +live blog+ wins over +live+.
62
+ MARKER_ALTERNATION = Regexp.union(TYPE_MARKERS.sort_by { -_1.length }).source
63
+ # One separator between a kicker and its headline: +: | • · – — - / > » › →+, each only when
64
+ # followed by whitespace, so a sign (+–5 Grad+), a path (+/e/+) or a German »quote« is not one, nor
65
+ # a comparison before a number (+> 50 Prozent+).
66
+ SEPARATOR_MARK = %r{(?:[:|•·–—/»›→-]|>(?![[:space:]]*\d))(?=[[:space:]]|\z)}
67
+ # Screen-reader field labels (+Section:+, +Artikel:+) and type labels with their marker
68
+ # (+Article type: Video+). Each field label needs its colon: a bare +Article+ is a word. A letter with
69
+ # an accented variant is an alternation, not a class: with +/i+, Ruby on Linux does not fold an
70
+ # uppercase accented letter (+Ä+) against a class that also holds its ASCII base (+[äa]+).
71
+ LABEL = /
72
+ (?:section|category|topic|rubrik|ressort|kategorie|thema|rubrique|cat(?:é|e)gorie|th(?:è|e)me|secci(?:ó|o)n
73
+ |categor(?:í|i)a|tema|sezione|categoria|argomento|rubrica|sectie|categorie|onderwerp|rubriek
74
+ |sekcja|kategoria|temat|dzia(?:ł|l)|sektion|kategori|(?:ä|a)mne
75
+ |article|artikel|art(?:í|i)culo|articolo|artyku(?:ł|l)
76
+ |セクション|カテゴリー?|ジャンル|分類|記事)\s*[::]
77
+ | (?:article\s+type|content\s+type|artikeltyp|beitragstyp|inhaltstyp|type\s+d['’]article
78
+ |type\s+de\s+contenu|tipo\s+de\s+art(?:í|i)culo|tipo\s+de\s+contenido
79
+ |tipo\s+di\s+articolo|tipo\s+di\s+contenuto|artikeltype|inhoudstype|soort\s+artikel
80
+ |typ\s+artyku(?:ł|l)u|typ\s+tre(?:ś|s)ci|rodzaj\s+tre(?:ś|s)ci|innehållstyp
81
+ |記事の種類|記事タイプ|コンテンツの種類)\s*[::]
82
+ (?:\s*(?:#{MARKER_ALTERNATION})(?![[:alnum:]]))?
83
+ /ix
84
+ # A whole piece that is only a label: a screen-reader label element, never headline text.
85
+ LABEL_PIECE = /\A(?:#{LABEL})\z/
86
+ # Leading separators of a headline (+Kicker</span> – Headline+ leaves +– Headline+).
87
+ LEADING_SEPARATORS = /\A(?:[[:space:]]|#{SEPARATOR_MARK})+/
88
+ # A leading Japanese bracket (+【…】+ or +<…>+) of one to thirty characters, with headline text after it.
89
+ LEADING_BRACKET = /\A[【<][[:space:]]*([^】>]{1,30}?)[[:space:]]*[】>][[:space:]]*(?=\S)/
90
+ # A bracket naming a content type (+【速報】+): noise as a kicker.
91
+ BRACKET_LABEL = /[【<][[:space:]]*(?:#{MARKER_ALTERNATION})[[:space:]]*[】>]/i
92
+ # Leading kicker noise, repeated: separators, separated clocks, labels.
93
+ KICKER_NOISE = /
94
+ \A(?:
95
+ [[:space:]] | #{SEPARATOR_MARK} | #{LABEL} | #{BRACKET_LABEL}
96
+ | #{CLOCK}(?:\s*Uhr)?(?=[[:space:]]*(?:#{SEPARATOR_MARK}|\z))
97
+ )+
98
+ /x
99
+ # Trailing stray separators (+Kicker :+, +Kicker -+, +Kicker ›+). A trailing +»+ closes a French
100
+ # quote, so it stays.
101
+ TRAILING_SEPARATORS = %r{(?:[[:space:]]|[:|•·–—]|(?<=[[:space:]])[-/>›→])+\z}
102
+ # The inline kicker boundary of +Kicker : Headline+: a whitespace-preceded colon after at most
103
+ # eight words ending in a letter or closing quote/bracket, so a score (+16 : 9+) keeps its colon.
104
+ SPACED_COLON = /\A((?:[^[:space:]]+[[:space:]]+){0,7}?[^[:space:]]*[\p{L}"'“”’»›)\]])[[:space:]]+:[[:space:]]*/
105
+ # A kicker that already ends a sentence (+Kicker?+) joins its headline with a space, not a colon.
106
+ TERMINAL_PUNCTUATION = /[?!…‽]\z/
107
+ private_constant :MARKER_ALTERNATION, :LEADING_BRACKET, :BRACKET_LABEL, :LABEL, :LABEL_PIECE,
108
+ :LEADING_SEPARATORS, :KICKER_NOISE, :TRAILING_SEPARATORS, :SPACED_COLON, :TERMINAL_PUNCTUATION
109
+
110
+ class << self
111
+ ##
112
+ # @param text [String, nil] title text as extracted
113
+ # @return [String, nil] +text+ in NFC (a composed and a decomposed +Ä+ are one title) without
114
+ # {INVISIBLE} characters, whitespace runs (newlines included) collapsed and trimmed, or nil when blank
115
+ def collapse(text)
116
+ collapsed = text.to_s.unicode_normalize
117
+ collapsed = collapsed.gsub(INVISIBLE, '') if collapsed.match?(INVISIBLE)
118
+ collapsed = collapsed.gsub(/[[:space:]]+/, ' ').strip
119
+ collapsed unless collapsed.empty?
120
+ end
121
+
122
+ ##
123
+ # @param headline [String, nil] headline text as extracted; kept verbatim apart from separators
124
+ # @param kicker [String, nil] kicker / eyebrow text, when the source exposes one
125
+ # @return [String, nil] +Kicker: Headline+, the headline, the kicker when the headline is blank,
126
+ # or nil when both are blank
127
+ def compose(headline, kicker: nil)
128
+ head, top = peel_bracket(trim(headline, LEADING_SEPARATORS), trim(kicker, KICKER_NOISE))
129
+ return head if top.nil? || noise_kicker?(top)
130
+ return top unless head
131
+ return head if prefixed?(head, top)
132
+
133
+ "#{top}#{top.match?(TERMINAL_PUNCTUATION) ? ' ' : SEPARATOR}#{head}"
134
+ end
135
+
136
+ ##
137
+ # Whether a piece of text is only a clock time (+19:47+, +10:00 CET+). Description peels such chips.
138
+ #
139
+ # @param text [String, nil] visible text of one element or chip
140
+ # @return [Boolean]
141
+ def clock_piece?(text) = collapse(text).to_s.match?(CLOCK_PIECE)
142
+
143
+ ##
144
+ # Whether a piece of text is only a calendar date or a date with a clock time (+11月12日 16:42+).
145
+ # {Html::TitlePieces} drops such elements at either edge of the headline.
146
+ #
147
+ # @param text [String, nil] visible text of one headline descendant element
148
+ # @return [Boolean]
149
+ def date_piece?(text) = collapse(text).to_s.match?(DATE_PIECE)
150
+
151
+ ##
152
+ # Whether one element inside a headline is teaser noise on its own: only a clock time or only a
153
+ # label (+Section:+, +Article type: Video+). {Html::TitlePieces} drops such elements when they open
154
+ # the headline; the headline text itself is never noise-stripped.
155
+ #
156
+ # @param text [String, nil] visible text of one headline descendant element
157
+ # @return [Boolean]
158
+ def headline_noise_piece?(text)
159
+ piece = collapse(text).to_s
160
+ piece.match?(CLOCK_PIECE) || piece.match?(DATE_PIECE) || piece.match?(LABEL_PIECE)
161
+ end
162
+
163
+ ##
164
+ # Whether one child element of a kicker is noise on its own: blank, a clock time, a closed field
165
+ # label (+Thema:+, +Section:+) or a type marker. Any other piece ending in a colon is kicker text
166
+ # (+Conflict:+); {.compose} trims that colon. {Html::TitlePieces} drops noise kicker pieces.
167
+ #
168
+ # @param text [String, nil] visible text of one kicker child element
169
+ # @return [Boolean]
170
+ def noise_piece?(text)
171
+ piece = collapse(text)
172
+ piece.nil? || piece.match?(LABEL_PIECE) || piece.match?(CLOCK_PIECE) || TYPE_MARKERS.include?(piece.downcase)
173
+ end
174
+
175
+ private
176
+
177
+ def trim(text, prefix)
178
+ return unless (collapsed = collapse(text))
179
+
180
+ trimmed = collapsed.sub(prefix, '').sub(TRAILING_SEPARATORS, '').sub(SPACED_COLON, "\\1#{SEPARATOR}")
181
+ trimmed unless trimmed.empty?
182
+ end
183
+
184
+ # A leading bracket naming a content type leaves the headline; any other bracket text is the kicker
185
+ # unless the source already gave one (then the headline stays whole).
186
+ def peel_bracket(head, top)
187
+ return [head, top] unless (bracket = head&.match(LEADING_BRACKET))
188
+ return [bracket.post_match, top] if TYPE_MARKERS.include?(bracket[1].downcase)
189
+ return [head, top] if top
190
+
191
+ [bracket.post_match, bracket[1]]
192
+ end
193
+
194
+ def noise_kicker?(kicker) = TYPE_MARKERS.include?(kicker.downcase)
195
+
196
+ # Whether the headline already opens with the kicker as a whole word.
197
+ def prefixed?(head, kicker)
198
+ head[0, kicker.length].casecmp?(kicker) && !head[kicker.length].to_s.match?(/[[:alnum:]]/)
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
@@ -6,8 +6,12 @@ module Html2rss
6
6
  ##
7
7
  # Article is a simple data object representing an article extracted from a page.
8
8
  #
9
+ # Article owns the title via {Article::Title}. Every title is whitespace-collapsed; callers that extract titles
10
+ # from heuristic card markup opt in with +compose_title: true+ to join +kicker+ to +title+ (the headline) and strip
11
+ # noise. Configured selector titles and publisher-declared headlines (feeds, structured data) stay verbatim.
12
+ #
9
13
  # Description and enclosure wire presentation live in {FeedBuilder::ItemPresentation}.
10
- class Article
14
+ class Article # rubocop:disable Metrics/ClassLength -- one reader per article attribute
11
15
  # Allowed article attributes accepted by the value object constructor.
12
16
  PROVIDED_KEYS = %i[id title description url image author guid published_at enclosures categories scraper].freeze
13
17
  # Separator used to build deterministic deduplication fingerprints.
@@ -19,36 +23,65 @@ module Html2rss
19
23
  NOT_SET = Object.new.freeze
20
24
 
21
25
  # @param id [String, nil] stable article identifier
22
- # @param title [String, nil] article title
26
+ # @param title [String, nil] article headline
27
+ # @param kicker [String, nil] kicker / eyebrow text composed into the title (not stored); used only with
28
+ # +compose_title+
23
29
  # @param description [String, nil] raw extracted description/content (not feed-rendered HTML)
24
30
  # @param url [String, Html2rss::Url, nil] canonical article URL
25
- # @param image [String, Html2rss::Url, nil] image URL for description / JSON Feed +image+
31
+ # @param image [String, Html2rss::Url, nil] image URL for description / JSON Feed +image+; without one, the
32
+ # first image file among +enclosures+ ({Enclosure#image?}) is the item image
26
33
  # @param author [String, nil] author name
27
34
  # @param guid [String, Array, nil] explicit GUID override
28
35
  # @param published_at [String, Time, DateTime, nil] publication timestamp
29
36
  # @param enclosures [Array<Hash{Symbol => Object}>, nil] enclosure attribute hashes
30
37
  # @param categories [Array<String>, nil] category labels
31
38
  # @param scraper [Class, nil] scraper class that produced the article
32
- # rubocop:disable-next Metrics/ParameterLists -- eleven article keys stay explicit
33
- def initialize(id: nil, title: nil, description: nil, url: nil, image: nil, author: nil, guid: nil,
34
- published_at: nil, enclosures: nil, categories: nil, scraper: nil)
35
- @to_h = { id:, title:, description:, url:, image:, author:, guid:, published_at:, enclosures:, categories:,
36
- scraper: }.compact.transform_values { freeze_option(_1) }.freeze
39
+ # @param media [Boolean, nil] whether the item is an audio/video object (not an article)
40
+ # @param compose_title [Boolean] opt in to kicker joining and noise stripping; default keeps the title verbatim
41
+ # rubocop:disable-next Metrics/ParameterLists -- thirteen article inputs stay explicit
42
+ def initialize(id: nil, title: nil, kicker: nil, description: nil, url: nil, image: nil, author: nil, guid: nil,
43
+ published_at: nil, enclosures: nil, categories: nil, scraper: nil, media: nil, compose_title: false)
44
+ title = compose_title ? Title.compose(title, kicker:) : Title.collapse(title)
45
+ image = first_image(enclosures) unless present?(image)
46
+ @to_h = { id:, title:, description:, url:, image:, author:, guid:, published_at:,
47
+ enclosures:, categories:, scraper:, media: }.compact.transform_values { freeze_option(_1) }.freeze
37
48
 
38
49
  @url = @image = @guid = @enclosures = @categories = @published_at = NOT_SET
39
50
  end
40
51
 
52
+ # Sole owner of the "an item needs a URL" rule: auto-source ({#valid?}) and selector items
53
+ # ({Html2rss::Selectors#each}) both admit only linked articles.
54
+ # @return [Boolean] true when the article carries a URL
55
+ def linked? = !url.to_s.empty?
56
+
41
57
  # Checks if the article is valid based on the presence of URL, ID, and either title or description.
42
58
  # @return [Boolean] True if the article is valid, otherwise false.
43
59
  def valid?
44
- !url.to_s.empty? && (!title.to_s.empty? || !description.to_s.empty?) && !id.to_s.empty?
60
+ linked? && (!title.to_s.empty? || !description.to_s.empty?) && !id.to_s.empty?
61
+ end
62
+
63
+ # @param new_url [String, Html2rss::Url] replacement article URL
64
+ # @return [Article] a copy of this article carrying +new_url+
65
+ def with_url(new_url) = self.class.new(**@to_h, url: new_url)
66
+
67
+ # Folds a later copy of the same article into this one: every field this copy carries wins
68
+ # (title, date, guid, description, scraper), and the other copy fills only the fields it lacks.
69
+ #
70
+ # @param other [Article] another copy of this article (same {Url#article_identity})
71
+ # @return [Article] self when +other+ adds nothing, else a copy carrying the filled fields
72
+ def merge(other)
73
+ filled = other.provided.reject { |key, _| present?(@to_h[key]) }
74
+ filled.empty? ? self : self.class.new(**@to_h, **filled)
45
75
  end
46
76
 
47
77
  # @return [String, nil] stable article identifier
48
78
  def id = blank_string_to_nil(@to_h[:id])
49
79
 
50
- # @return [String, nil] article title
51
- def title = blank_string_to_nil(@to_h[:title])
80
+ # @return [Boolean] whether the scraper marked this item as an audio/video object
81
+ def media? = @to_h[:media] == true
82
+
83
+ # @return [String, nil] whitespace-collapsed title; +Kicker: Headline+ without noise when composed
84
+ def title = @to_h[:title]
52
85
 
53
86
  # Raw extracted description — feed HTML enrichment is {FeedBuilder::ItemPresentation.description_for}.
54
87
  #
@@ -59,14 +92,17 @@ module Html2rss
59
92
  def url
60
93
  return @url unless @url == NOT_SET
61
94
 
62
- @url = Url.sanitize(@to_h[:url])
95
+ @url = http_url(@to_h[:url]) || Url.sanitize(@to_h[:url])
63
96
  end
64
97
 
98
+ # The item image a feed reader and the description's +img+ load: an http(s) URL ({Url#web?}), never an
99
+ # +ftp:+ or +mailto:+ one.
100
+ #
65
101
  # @return [Url, nil]
66
102
  def image
67
103
  return @image unless @image == NOT_SET
68
104
 
69
- @image = Url.sanitize(@to_h[:image])
105
+ @image = Url.sanitize(@to_h[:image]).then { _1 if _1&.web? }
70
106
  end
71
107
 
72
108
  # @return [String, nil]
@@ -88,12 +124,16 @@ module Html2rss
88
124
  dedup_from_url || dedup_from_id || dedup_from_guid || hash
89
125
  end
90
126
 
127
+ # The item's downloadable files ({Enclosure#file?}): a watch or player page, stream playlist or Flash player
128
+ # is never an enclosure, so neither feed format ships one.
129
+ #
91
130
  # @return [Array<Html2rss::Article::Enclosure>] normalized enclosure objects
92
131
  def enclosures
93
132
  return @enclosures unless @enclosures == NOT_SET
94
133
 
95
134
  @enclosures = Array(@to_h[:enclosures])
96
135
  .map { |enclosure| Enclosure.new(**enclosure) }
136
+ .select(&:file?)
97
137
  .freeze
98
138
  end
99
139
 
@@ -124,8 +164,34 @@ module Html2rss
124
164
  @to_h[:scraper]
125
165
  end
126
166
 
167
+ protected
168
+
169
+ # @return [Hash{Symbol => Object}] the constructor payload, for {#merge}
170
+ def provided = @to_h
171
+
127
172
  private
128
173
 
174
+ # The item image of an article that declares none: its first image file, which stays an enclosure.
175
+ def first_image(enclosures)
176
+ Array(enclosures).each do |enclosure|
177
+ file = Enclosure.new(**enclosure)
178
+ return file.url if file.image?
179
+ end
180
+ nil
181
+ end
182
+
183
+ def present?(value)
184
+ case value
185
+ when String, Array then !value.empty?
186
+ else !value.nil?
187
+ end
188
+ end
189
+
190
+ # A {Url} is always normalized; http(s) ones need no re-parse. Others go through +Url.sanitize+.
191
+ def http_url(value)
192
+ value if value.is_a?(Url) && value.web?
193
+ end
194
+
129
195
  ##
130
196
  # Marshal only the constructor payload so lazy +NOT_SET+ sentinels do not break
131
197
  # {FeedResult} cache round-trips.