html2rss 0.33.0 → 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 (177) 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 -21
  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 +126 -50
  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 -39
  16. data/lib/html2rss/auto_source/scraper/json_document.rb +1 -3
  17. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +119 -42
  18. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +5 -16
  19. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +2 -10
  20. data/lib/html2rss/auto_source/scraper/json_state.rb +2 -3
  21. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +6 -14
  22. data/lib/html2rss/auto_source/scraper/microdata.rb +6 -4
  23. data/lib/html2rss/auto_source/scraper/microformats2.rb +1 -3
  24. data/lib/html2rss/auto_source/scraper/native_feed.rb +27 -17
  25. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
  26. data/lib/html2rss/auto_source/scraper/schema/thing.rb +68 -27
  27. data/lib/html2rss/auto_source/scraper/schema.rb +5 -8
  28. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
  29. data/lib/html2rss/auto_source/scraper/semantic_html.rb +1 -5
  30. data/lib/html2rss/auto_source/scraper/sitemap.rb +4 -3
  31. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -3
  32. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -3
  33. data/lib/html2rss/auto_source/scraper.rb +10 -2
  34. data/lib/html2rss/auto_source/segment.rb +19 -5
  35. data/lib/html2rss/auto_source/segmenter/cluster.rb +13 -11
  36. data/lib/html2rss/auto_source/segmenter/list.rb +13 -5
  37. data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
  38. data/lib/html2rss/auto_source/segmenter/semantic.rb +17 -6
  39. data/lib/html2rss/auto_source/segmenter.rb +4 -8
  40. data/lib/html2rss/auto_source.rb +81 -26
  41. data/lib/html2rss/batch.rb +2 -0
  42. data/lib/html2rss/capture/README.md +5 -2
  43. data/lib/html2rss/capture.rb +44 -32
  44. data/lib/html2rss/channel.rb +30 -12
  45. data/lib/html2rss/cli.rb +22 -11
  46. data/lib/html2rss/config/auto_source_contract.rb +52 -45
  47. data/lib/html2rss/config/issue_mapper.rb +4 -1
  48. data/lib/html2rss/config/request_controls.rb +6 -3
  49. data/lib/html2rss/config/schema.rb +15 -5
  50. data/lib/html2rss/config/selectors_validator.rb +2 -2
  51. data/lib/html2rss/config/validation_issue.rb +4 -1
  52. data/lib/html2rss/config/validator.rb +11 -0
  53. data/lib/html2rss/config.rb +53 -27
  54. data/lib/html2rss/contract_error.rb +9 -0
  55. data/lib/html2rss/defaults.rb +21 -23
  56. data/lib/html2rss/doctor/botasaurus.rb +7 -3
  57. data/lib/html2rss/error.rb +10 -0
  58. data/lib/html2rss/error_kind.rb +39 -0
  59. data/lib/html2rss/feed_builder/item_presentation.rb +22 -9
  60. data/lib/html2rss/feed_builder/json_feed/item.rb +2 -1
  61. data/lib/html2rss/feed_builder/json_feed.rb +2 -2
  62. data/lib/html2rss/feed_builder/rss/stylesheet.rb +3 -3
  63. data/lib/html2rss/feed_builder/rss.rb +49 -6
  64. data/lib/html2rss/feed_builder.rb +2 -1
  65. data/lib/html2rss/feed_pipeline/README.md +1 -1
  66. data/lib/html2rss/feed_pipeline/auto_fallback.rb +13 -31
  67. data/lib/html2rss/feed_pipeline/strategy_plan.rb +3 -3
  68. data/lib/html2rss/feed_pipeline.rb +40 -27
  69. data/lib/html2rss/feed_resolution/README.md +1 -1
  70. data/lib/html2rss/feed_resolution/candidate_generator.rb +3 -4
  71. data/lib/html2rss/feed_resolution/policy.rb +1 -1
  72. data/lib/html2rss/feed_resolution/probe.rb +3 -1
  73. data/lib/html2rss/feed_resolution.rb +11 -12
  74. data/lib/html2rss/feed_result.rb +9 -5
  75. data/lib/html2rss/hash_util.rb +3 -3
  76. data/lib/html2rss/html/article_extractor/category_extractor.rb +1 -1
  77. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -31
  78. data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
  79. data/lib/html2rss/html/article_extractor/id_generator.rb +6 -9
  80. data/lib/html2rss/html/article_extractor/image_extractor.rb +9 -1
  81. data/lib/html2rss/html/article_extractor.rb +19 -47
  82. data/lib/html2rss/html/article_rules/category.rb +3 -3
  83. data/lib/html2rss/html/article_rules/description.rb +6 -15
  84. data/lib/html2rss/html/article_rules/enclosure.rb +70 -42
  85. data/lib/html2rss/html/article_rules/image.rb +30 -16
  86. data/lib/html2rss/html/feed_link.rb +7 -9
  87. data/lib/html2rss/html/navigator/text_extractor.rb +26 -16
  88. data/lib/html2rss/html/navigator.rb +39 -9
  89. data/lib/html2rss/html/probe.rb +4 -0
  90. data/lib/html2rss/html/rendering/description_builder.rb +46 -32
  91. data/lib/html2rss/html/rendering.rb +2 -9
  92. data/lib/html2rss/html/sst_article_extractor.rb +55 -145
  93. data/lib/html2rss/html/title_pieces/kicker.rb +147 -0
  94. data/lib/html2rss/html/title_pieces/nodes.rb +107 -0
  95. data/lib/html2rss/html/title_pieces/nokogiri_nodes.rb +70 -0
  96. data/lib/html2rss/html/title_pieces/sst_nodes.rb +71 -0
  97. data/lib/html2rss/html/title_pieces.rb +169 -0
  98. data/lib/html2rss/html/visibility.rb +56 -0
  99. data/lib/html2rss/html.rb +2 -0
  100. data/lib/html2rss/link_destination/destination_facts.rb +17 -1
  101. data/lib/html2rss/link_destination/noise_policy.rb +11 -10
  102. data/lib/html2rss/link_destination/path_classifier.rb +100 -9
  103. data/lib/html2rss/link_destination/sponsorship/label_text.rb +82 -0
  104. data/lib/html2rss/link_destination/sponsorship.rb +184 -0
  105. data/lib/html2rss/link_destination/text_classifier.rb +104 -8
  106. data/lib/html2rss/mcp/README.md +13 -1
  107. data/lib/html2rss/mcp/config_argument.rb +5 -2
  108. data/lib/html2rss/mcp/contract.rb +13 -4
  109. data/lib/html2rss/mcp/outcome.rb +14 -12
  110. data/lib/html2rss/mcp/runtime.rb +39 -2
  111. data/lib/html2rss/mcp/server.rb +13 -4
  112. data/lib/html2rss/page_recon/README.md +1 -1
  113. data/lib/html2rss/page_recon/diagnostics.rb +2 -0
  114. data/lib/html2rss/recon.rb +10 -12
  115. data/lib/html2rss/request_service/blocked_surface.rb +2 -1
  116. data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
  117. data/lib/html2rss/request_service/botasaurus_strategy.rb +42 -10
  118. data/lib/html2rss/request_service/context.rb +15 -14
  119. data/lib/html2rss/request_service/cookie_jar.rb +89 -0
  120. data/lib/html2rss/request_service/cookie_store.rb +79 -0
  121. data/lib/html2rss/request_service/feed_advertisement.rb +72 -0
  122. data/lib/html2rss/request_service/httpx_strategy.rb +7 -3
  123. data/lib/html2rss/request_service/local_file_strategy.rb +2 -2
  124. data/lib/html2rss/request_service/policy.rb +48 -11
  125. data/lib/html2rss/request_service/replay_breaker.rb +56 -0
  126. data/lib/html2rss/request_service/response.rb +30 -13
  127. data/lib/html2rss/request_service/response_cache.rb +109 -0
  128. data/lib/html2rss/request_service/response_caching.rb +95 -0
  129. data/lib/html2rss/request_service/strategy.rb +5 -11
  130. data/lib/html2rss/request_service.rb +71 -12
  131. data/lib/html2rss/request_session/pager/base.rb +0 -7
  132. data/lib/html2rss/request_session/pager.rb +1 -1
  133. data/lib/html2rss/request_session.rb +6 -2
  134. data/lib/html2rss/scoring/cluster_scorer.rb +1 -1
  135. data/lib/html2rss/scoring/container_assessor.rb +12 -13
  136. data/lib/html2rss/scoring/engine.rb +6 -6
  137. data/lib/html2rss/scoring/observation.rb +2 -2
  138. data/lib/html2rss/selector_candidates.rb +19 -8
  139. data/lib/html2rss/selectors/README.md +1 -1
  140. data/lib/html2rss/selectors/option_spec.rb +4 -1
  141. data/lib/html2rss/selectors/post_processors/base.rb +0 -8
  142. data/lib/html2rss/selectors/post_processors/gsub.rb +7 -7
  143. data/lib/html2rss/selectors/post_processors/html_transformers/keep_playable_media.rb +50 -0
  144. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +1 -3
  145. data/lib/html2rss/selectors/post_processors/parse_time.rb +3 -3
  146. data/lib/html2rss/selectors/post_processors/parse_uri.rb +1 -1
  147. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +18 -5
  148. data/lib/html2rss/selectors/post_processors/substring.rb +1 -1
  149. data/lib/html2rss/selectors.rb +53 -12
  150. data/lib/html2rss/sst/attrs.rb +2 -2
  151. data/lib/html2rss/sst/document.rb +3 -3
  152. data/lib/html2rss/sst/index.rb +30 -11
  153. data/lib/html2rss/sst/node.rb +33 -26
  154. data/lib/html2rss/sst/normalizer.rb +31 -19
  155. data/lib/html2rss/sst/tags.rb +113 -2
  156. data/lib/html2rss/sst/text.rb +69 -29
  157. data/lib/html2rss/sst.rb +2 -0
  158. data/lib/html2rss/status.rb +6 -3
  159. data/lib/html2rss/surface_category.rb +1 -1
  160. data/lib/html2rss/syndication/README.md +12 -6
  161. data/lib/html2rss/syndication/alternate_links.rb +1 -1
  162. data/lib/html2rss/syndication/channel_facts.rb +87 -0
  163. data/lib/html2rss/syndication/discovery.rb +82 -28
  164. data/lib/html2rss/syndication/feed.rb +20 -0
  165. data/lib/html2rss/syndication/item_media.rb +103 -0
  166. data/lib/html2rss/syndication/parser.rb +75 -51
  167. data/lib/html2rss/test/enhance_audit.rb +2 -2
  168. data/lib/html2rss/test/item_sample.rb +43 -0
  169. data/lib/html2rss/test.rb +42 -14
  170. data/lib/html2rss/url.rb +115 -14
  171. data/lib/html2rss/version.rb +1 -1
  172. data/lib/html2rss/word_count.rb +53 -0
  173. data/lib/html2rss.rb +17 -8
  174. data/schema/html2rss-config.schema.json +56 -41
  175. metadata +31 -4
  176. data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
  177. data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ class PageLinks
6
+ ##
7
+ # The card units of one SST subtree: its largest one-card subtrees ({Html::CardWalk.crowded?}: one
8
+ # destination, at most one heading) that hold a destination and are rooted outside a heading (a
9
+ # heading holding a link is a headline, not a card). Headings outside every card unit are stray.
10
+ # Bare +div+ cards count like +article+ or +li+ ones: the unit is read from structure, not tag names.
11
+ class CardUnits
12
+ Unit = Data.define(:keys, :headings, :cards, :peers, :stray)
13
+ ##
14
+ # Destinations and headings of one subtree, and the card units and stray headings its children hold.
15
+ class Unit
16
+ # The empty unit (a left-out subtree).
17
+ NONE = new(keys: [].freeze, headings: 0, cards: 0, peers: false, stray: false)
18
+
19
+ # @return [Boolean] whether the unit holds more than one card
20
+ def crowded? = Html::CardWalk.crowded?(heading_count: headings, distinct_main_hrefs: keys.size)
21
+ end
22
+ private_constant :Unit
23
+
24
+ ##
25
+ # @param root [SST::Node] subtree to read
26
+ # @param skip [SST::Node, nil] a subtree left out (a section's own label)
27
+ # @param claims [AnchorClaims] resolves an anchor's destination
28
+ def initialize(root, skip:, claims:)
29
+ @skip = skip
30
+ @claims = claims
31
+ @unit = survey(root)
32
+ freeze
33
+ end
34
+
35
+ # @return [Integer] card units below the root
36
+ def cards = @unit.cards
37
+
38
+ # @return [Boolean] whether a card unit is a child of the root
39
+ def peers? = @unit.peers
40
+
41
+ # @return [Boolean] whether the root itself is one card unit holding a heading
42
+ def card? = !@unit.crowded? && @unit.keys.any? && @unit.headings == 1
43
+
44
+ # @return [Boolean] whether a heading below the root sits outside every card unit
45
+ def stray? = @unit.stray
46
+
47
+ private
48
+
49
+ def survey(node)
50
+ return Unit::NONE if node.equal?(@skip)
51
+
52
+ units = node.children.map { survey(_1) }
53
+ cards, peers, stray = split(node.children, units)
54
+ Unit.new(keys: keys(node, units), headings: headings(node, units), cards:, peers:, stray:)
55
+ end
56
+
57
+ def keys(node, units)
58
+ keys = units.flat_map(&:keys)
59
+ keys << @claims.key_of(node) if node.link?
60
+ keys.compact.uniq
61
+ end
62
+
63
+ def headings(node, units) = units.sum(&:headings) + (node.heading? ? 1 : 0)
64
+
65
+ def split(children, units)
66
+ children.zip(units).reduce([0, false, false]) do |(cards, peers, stray), (child, unit)|
67
+ next [cards + unit.cards, peers, stray || unit.stray] if unit.crowded?
68
+ next [cards + 1, true, stray] if unit.keys.any? && !child.heading?
69
+
70
+ [cards, peers, stray || unit.headings.positive?]
71
+ end
72
+ end
73
+ end
74
+ private_constant :CardUnits
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ class PageLinks
6
+ ##
7
+ # Whether a +:paid_label+ outside every anchor marks the frame where it binds: its first ancestor
8
+ # holding a destination after it ({SponsoredCards}). The label marks that frame when it is
9
+ #
10
+ # - the label of one card: the frame is one card unit with a heading ({CardUnits#card?}) and the label
11
+ # sits in it alone ({#alone?}), not in a box beside the card that holds more (an ad slot); or
12
+ # - the header of a section ({#header?}): the frame is no page region and holds no heading outside its
13
+ # card units.
14
+ #
15
+ # A label in plain text before cards (an ad caption over an empty or stripped slot) heads nothing.
16
+ class LabelBinding
17
+ # Elements that hold a page, never a section: a section label stops below them.
18
+ PAGE_REGIONS = Set[:html, :body, :main].freeze
19
+ # Class-token words (split on +-+/+_+) of a header box.
20
+ HEADER_WORDS = Set['header', 'head', 'heading'].freeze
21
+ # Text that holds a word.
22
+ WORD = /[[:alnum:]]/
23
+ private_constant :HEADER_WORDS, :WORD
24
+
25
+ ##
26
+ # @param node [SST::Node] the frame where the label binds
27
+ # @param label [SST::Node] the label element
28
+ # @param claims [AnchorClaims] resolves the frame's destinations
29
+ def initialize(node, label, claims)
30
+ @node = node
31
+ @label = label
32
+ @claims = claims
33
+ end
34
+
35
+ ##
36
+ # @return [Boolean] whether the label marks every destination of the frame
37
+ def marks?
38
+ units = CardUnits.new(@node, skip: @label, claims: @claims)
39
+ (units.card? && alone?) || section?(units)
40
+ end
41
+
42
+ private
43
+
44
+ def section?(units)
45
+ return false if @node.find { PAGE_REGIONS.include?(_1.name) }
46
+
47
+ !units.stray? && header?
48
+ end
49
+
50
+ # Whether the label heads the frame: it sits in the frame's first child holding words, and it or a
51
+ # wrapper of it there is a heading, a +header+ element or a header box ({HEADER_WORDS}).
52
+ def header?
53
+ first = @node.children.find { _1.visible_text.to_s.match?(WORD) }
54
+ path.last.equal?(first) && path.any? { _1.heading? || _1.name == :header || header_box?(_1) }
55
+ end
56
+
57
+ def header_box?(node)
58
+ node.attrs.class_names.any? { |token| token.downcase.split(/[-_]+/).any? { HEADER_WORDS.include?(_1) } }
59
+ end
60
+
61
+ # Whether the frame's child around the label holds nothing but the label.
62
+ def alone?
63
+ path.drop(1).all? { _1.children.one? && !_1.own_text.match?(WORD) }
64
+ end
65
+
66
+ # The label and its wrappers up to the frame's child holding it, innermost first.
67
+ def path
68
+ @path ||= begin
69
+ index = SST::Index.for_node(@label)
70
+ nodes = [@label]
71
+ nodes << index.parent_of(nodes.last) until index.parent_of(nodes.last).equal?(@node)
72
+ nodes
73
+ end
74
+ end
75
+ end
76
+ private_constant :LabelBinding
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ class PageLinks
6
+ ##
7
+ # Article identities of sponsored cards, from one marker walk ({LinkDestination::Sponsorship.marker}).
8
+ # Anchors are resolved only once a marker binds; a page without markers resolves nothing.
9
+ #
10
+ # Each marker binds in one scope:
11
+ #
12
+ # - A +:container+ marker holding anchors marks every destination inside it.
13
+ # - A marker inside an anchor (a linked label) marks that anchor's destination across its card unit: the
14
+ # largest unit around the anchor that is still one card ({Html::CardWalk.crowded?}: one destination,
15
+ # at most one heading), and only when that unit holds a heading.
16
+ # - Any other label (or an anchorless container marker) marks the card it sits in: its nearest
17
+ # {LinkDestination::Sponsorship::CARD_NAMES} element, itself included. That card marks its dominant
18
+ # destination: its only one, else its only headline destination (an anchor in or around a heading);
19
+ # a card with several headlines marks nothing, and so does a label outside every card.
20
+ # - A +:paid_label+ outside every anchor binds at its first ancestor holding a destination after it
21
+ # (none before it) and climbs no further. There {LabelBinding} decides whether it marks every
22
+ # destination of that frame: the one card it labels alone, or the section it heads.
23
+ #
24
+ # Marking is per card: a URL is sponsored only when every anchor to it sits in a marked card,
25
+ # so a promo slot never hides the editorial card linking the same story.
26
+ class SponsoredCards
27
+ LinkedLabel = Data.define(:range, :headings)
28
+ ##
29
+ # A label inside an anchor, climbing to its card unit: the anchor range and heading count of the
30
+ # largest one-card unit around it so far. It starts empty and takes the anchor's frame first.
31
+ class LinkedLabel
32
+ # A unit not yet measured.
33
+ OPEN = new(range: (0...0), headings: 0)
34
+ end
35
+
36
+ # A +:paid_label+ outside every anchor, climbing to the section it may head: the label node and the
37
+ # anchor count before and after it.
38
+ SectionLabel = Data.define(:label, :before, :after)
39
+
40
+ private_constant :LinkedLabel, :SectionLabel
41
+
42
+ # @param document [SST::Document, nil]
43
+ # @param link_resolver [Scoring::LinkResolver, nil]
44
+ def initialize(document, link_resolver)
45
+ @claims = AnchorClaims.new(link_resolver)
46
+ @linked = Set.new
47
+ @ancestors, @labelled, @links = Array.new(3) { [] }
48
+ @headings = 0
49
+ # Linked labels still climbing at the root bind there.
50
+ walk(document.root)&.each { bind_link(_1) if _1.is_a?(LinkedLabel) } if document
51
+ @keys = @claims.sponsored_keys
52
+ freeze
53
+ end
54
+
55
+ # @param key [Array] article identity
56
+ # @return [Boolean]
57
+ def include?(key) = @keys.include?(key)
58
+
59
+ private
60
+
61
+ # Returns the labels still climbing out of +node+ (nil when none). +@labelled+ holds one flag per
62
+ # open card, innermost last; +@links+ the anchor index of each open anchor.
63
+ def walk(node)
64
+ start = @claims.size
65
+ headings = @headings
66
+ card = LinkDestination::Sponsorship::CARD_NAMES.include?(node.name)
67
+ @labelled << false if card
68
+ pending = mark(node, start, descend(node))
69
+ pending = append(pending, [LinkedLabel::OPEN]) if node.link? && @linked.delete?(start)
70
+ bound = bind(start) if card && @labelled.pop
71
+ pending && settle(node, start, @headings - headings, pending, bound:)
72
+ end
73
+
74
+ def descend(node)
75
+ link = node.link?
76
+ record(node) if link
77
+ @headings += 1 if node.heading?
78
+ @ancestors << node
79
+ pending = nil
80
+ node.children.each { pending = append(pending, walk(_1)) }
81
+ @ancestors.pop
82
+ @links.pop if link
83
+ pending
84
+ end
85
+
86
+ def record(anchor) = @links << @claims.record(anchor, in_heading: @ancestors.any?(&:heading?))
87
+
88
+ def mark(node, start, pending)
89
+ marker = LinkDestination::Sponsorship.marker(node, @ancestors)
90
+ return pending unless marker
91
+ return pending.tap { @claims.claim(start...@claims.size) } if marker == :container && @claims.size > start
92
+ return pending.tap { @linked << @links.last } unless @links.empty?
93
+
94
+ @labelled[-1] = true unless @labelled.empty?
95
+ return pending unless marker == :paid_label
96
+
97
+ append(pending, [SectionLabel.new(label: node, before: start, after: @claims.size)])
98
+ end
99
+
100
+ def append(pending, labels) = pending && labels ? pending.concat(labels) : pending || labels
101
+
102
+ # Moves each climbing label to +node+'s frame; returns the labels that climb on. A section label
103
+ # stops at a card or page region: {#section?} would reject anything above, so it saves the survey.
104
+ def settle(node, start, headings, pending, bound:)
105
+ range = start...@claims.size
106
+ boundary = LinkDestination::Sponsorship::CARD_NAMES.include?(node.name) || LabelBinding::PAGE_REGIONS.include?(node.name)
107
+ climbing = pending.filter_map do |label|
108
+ case label
109
+ in LinkedLabel then climb(label, range, headings)
110
+ in SectionLabel then head(label, node, range, boundary:, bound:)
111
+ end
112
+ end
113
+ climbing unless climbing.empty?
114
+ end
115
+
116
+ # Grows a linked label's unit while it stays one card; binds it at the first crowded frame.
117
+ def climb(label, range, headings)
118
+ crowded = Html::CardWalk.crowded?(heading_count: headings, distinct_main_hrefs: @claims.keys_in(range).size)
119
+ crowded ? bind_link(label) : label.with(range:, headings:)
120
+ end
121
+
122
+ # Claims the unit (one destination at most) when it holds a heading; returns nil.
123
+ def bind_link(label)
124
+ @claims.claim(label.range) if label.headings == 1
125
+ nil
126
+ end
127
+
128
+ # A section label stops at a bound card, at an anchor before it, and at a card or page region holding
129
+ # no destination after it. At its first frame holding one it marks that card or section, then stops.
130
+ def head(label, node, range, boundary:, bound:)
131
+ return if bound || range.begin < label.before
132
+ return (label unless boundary) unless range.end > label.after
133
+
134
+ @claims.claim(range) if LabelBinding.new(node, label.label, @claims).marks?
135
+ nil
136
+ end
137
+
138
+ def bind(start)
139
+ range = start...@claims.size
140
+ key = @claims.dominant_key(range)
141
+ @claims.claim(range, [key]) if key
142
+ end
143
+ end
144
+ private_constant :SponsoredCards
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ class AutoSource
5
+ ##
6
+ # Where each URL is rendered on the page, and which URLs the page marks as sponsored.
7
+ #
8
+ # Keys are {Html2rss::Url#article_identity}, so scheme, tracking query params, trailing
9
+ # slashes and fragments do not split a URL; other query params stay significant. Chrome and
10
+ # peripheral both come from the one SST scope rule ({SST::Tags.chrome_scope}, read through
11
+ # {SST::Index#ignored_chrome?} and {SST::Index#peripheral?}): utility landmarks that are not
12
+ # chrome (+aside+, +menu+, role=complementary, in-content +nav+, sectioned +footer+) are peripheral.
13
+ # A URL rendered several times keeps its strongest placement.
14
+ #
15
+ # Each {LinkDestination::Sponsorship} marker is scoped by {SponsoredCards}: a container marks every
16
+ # destination inside it, a linked label its anchor's card unit, a label the dominant destination of
17
+ # the +article+, +section+, +aside+ or +li+ it sits in, and a paid label opening a section every card
18
+ # of that section. PageLinks owns no keep or drop rule; {Corroboration} and {Cleanup} read it.
19
+ #
20
+ # Both facts are walked lazily, once, on first use: a sponsorship check walks markers and
21
+ # resolves only the anchors of marked cards; placement, canonical and sparse checks resolve
22
+ # every anchor.
23
+ class PageLinks
24
+ # Placement strength, strongest first; a URL missing from the page is +:absent+.
25
+ RANKS = { content: 0, peripheral: 1, chrome: 2 }.freeze
26
+ # A page with at most this many distinct content URLs is too sparse to corroborate.
27
+ SPARSE_MAX_CONTENT_LINKS = 2
28
+
29
+ ##
30
+ # @param document [SST::Document, nil] page SST; nil when there is no rendered page
31
+ # @param link_resolver [Scoring::LinkResolver, nil] page resolver (shared memo with heuristics);
32
+ # required when +document+ is present
33
+ # @return [PageLinks]
34
+ def self.build(document, link_resolver:)
35
+ new(document, link_resolver)
36
+ end
37
+ private_class_method :new
38
+
39
+ # @param document [SST::Document, nil]
40
+ # @param link_resolver [Scoring::LinkResolver, nil]
41
+ def initialize(document, link_resolver)
42
+ @document = document
43
+ @link_resolver = link_resolver
44
+ @walks = {}
45
+ freeze
46
+ end
47
+
48
+ ##
49
+ # @param url [Html2rss::Url, nil] candidate item URL
50
+ # @return [Symbol] +:content+, +:peripheral+, +:chrome+, or +:absent+
51
+ def placement(url)
52
+ return :absent unless url
53
+
54
+ placements.placement(url.article_identity)
55
+ end
56
+
57
+ ##
58
+ # The rendered content anchor's URL for the article +url+ names, minus tracking params,
59
+ # so a tracked or +http+ variant takes one stable form (the feed item guid keys on it).
60
+ # An +https+ item never drops to an +http+ anchor.
61
+ #
62
+ # @param url [Html2rss::Url, nil] candidate item URL
63
+ # @return [Html2rss::Url, nil] nil unless +url+ is placed as +:content+
64
+ def canonical_url(url)
65
+ anchor = url && placements.content_url(url.article_identity)&.without_tracking_params
66
+ return anchor unless anchor && url.scheme == 'https'
67
+
68
+ anchor.with_scheme('https')
69
+ end
70
+
71
+ ##
72
+ # True when absence cannot be told from an unrendered list: no page, a degraded SST,
73
+ # or at most {SPARSE_MAX_CONTENT_LINKS} distinct content URLs.
74
+ #
75
+ # @return [Boolean]
76
+ def sparse? = placements.sparse?
77
+
78
+ ##
79
+ # Document position of the first content anchor to the article +url+ names, among the
80
+ # page's distinct content URLs (from the placement walk; no second walk).
81
+ #
82
+ # @param url [Html2rss::Url, nil] candidate item URL
83
+ # @return [Integer, nil] nil unless +url+ is placed as +:content+
84
+ def content_position(url)
85
+ url && content_positions[url.article_identity]
86
+ end
87
+
88
+ ##
89
+ # True when +url+ is rendered inside a card carrying a sponsorship marker.
90
+ #
91
+ # @param url [Html2rss::Url, nil] candidate item URL
92
+ # @return [Boolean]
93
+ def sponsored?(url)
94
+ return false unless url
95
+
96
+ sponsored_cards.include?(url.article_identity)
97
+ end
98
+
99
+ private
100
+
101
+ # The +@walks+ memo is the explicit lazy holder: the frozen PageLinks fills each walk once.
102
+ def placements = @walks[:placements] ||= Placements.new(@document, @link_resolver)
103
+
104
+ def sponsored_cards = @walks[:sponsored_cards] ||= SponsoredCards.new(@document, @link_resolver)
105
+
106
+ def content_positions = @walks[:content_positions] ||= placements.content_keys.each_with_index.to_h
107
+
108
+ ##
109
+ # Strongest placement and content anchor URL per article identity, from one walk that
110
+ # resolves every SST anchor.
111
+ class Placements
112
+ # @param document [SST::Document, nil]
113
+ # @param link_resolver [Scoring::LinkResolver, nil]
114
+ def initialize(document, link_resolver)
115
+ @by_key = {}
116
+ @content_urls = {}
117
+ @index = document&.index
118
+ @link_resolver = link_resolver
119
+ @index&.each_node { record(_1) if _1.link? }
120
+ @sparse = document.nil? || document.degraded || content_link_count <= SPARSE_MAX_CONTENT_LINKS
121
+ freeze
122
+ end
123
+
124
+ # @param key [String] article identity
125
+ # @return [Symbol] placement, or +:absent+
126
+ def placement(key) = @by_key.fetch(key, :absent)
127
+
128
+ # @param key [String] article identity
129
+ # @return [Html2rss::Url, nil] the content anchor URL for +key+
130
+ def content_url(key) = @content_urls[key]
131
+
132
+ # @return [Boolean]
133
+ def sparse? = @sparse
134
+
135
+ # @return [Array<String>] article identities placed as +:content+, in document order
136
+ def content_keys = @content_urls.keys
137
+
138
+ private
139
+
140
+ def record(anchor)
141
+ url = @link_resolver.destination_facts(anchor)&.url
142
+ return unless url
143
+
144
+ key = url.article_identity
145
+ placement = anchor_placement(anchor)
146
+ current = @by_key[key]
147
+ return unless current.nil? || RANKS.fetch(placement) < RANKS.fetch(current)
148
+
149
+ @by_key[key] = placement
150
+ @content_urls[key] = url if placement == :content
151
+ end
152
+
153
+ def anchor_placement(anchor)
154
+ return :chrome if @index.ignored_chrome?(anchor)
155
+
156
+ @index.peripheral?(anchor) ? :peripheral : :content
157
+ end
158
+
159
+ def content_link_count = @by_key.count { |_key, placement| placement == :content }
160
+ end
161
+ private_constant :Placements
162
+ end
163
+ end
164
+ end
@@ -4,7 +4,7 @@ module Html2rss
4
4
  class AutoSource
5
5
  module Scraper
6
6
  ##
7
- # Fallback HTML list/cluster scraper via SST pipeline
7
+ # Fallback HTML list scraper via SST pipeline
8
8
  # (Normalizer → Segmenter → Scoring::Engine → Html::SstArticleExtractor).
9
9
  class Html
10
10
  include Enumerable
@@ -41,7 +41,6 @@ module Html2rss
41
41
  # @param document [SST::Document, nil] memoized SST document from AutoSource
42
42
  # @param link_resolver [Scoring::LinkResolver, nil] shared page-scoped resolver
43
43
  # @param opts [Hash]
44
- # @option opts [Boolean] :fallback_anchorless keep anchorless cluster cards
45
44
  # @option opts [Integer] :minimum_selector_frequency list frequency floor
46
45
  # @option opts [Integer] :use_top_selectors list selector budget
47
46
  def initialize(parsed_body = nil, url:, document: nil, link_resolver: nil, **opts)
@@ -50,7 +49,6 @@ module Html2rss
50
49
  @provided_link_resolver = link_resolver
51
50
  @url = url
52
51
  @opts = opts
53
- @fallback_anchorless = opts.fetch(:fallback_anchorless, false)
54
52
  end
55
53
 
56
54
  ##
@@ -71,48 +69,22 @@ module Html2rss
71
69
  private
72
70
 
73
71
  def articles
74
- @articles ||= begin
75
- ranked = list_ranked
76
- if ranked.empty? && @fallback_anchorless
77
- materialize(cluster_ranked, fallback_anchorless: true)
78
- else
79
- materialize(ranked)
80
- end
81
- end
72
+ @articles ||= materialize(ranked_segments)
82
73
  end
83
74
 
84
75
  def ranked_segments
85
- @ranked_segments ||= begin
86
- ranked = list_ranked
87
- ranked = cluster_ranked if @fallback_anchorless && ranked.empty?
88
- ranked
89
- end
90
- end
91
-
92
- def list_ranked
93
- @list_ranked ||= document ? rank_strategy(:list, permit_unanchored: false) : []
94
- end
95
-
96
- def cluster_ranked
97
- @cluster_ranked ||= document ? rank_strategy(:cluster, permit_unanchored: true) : []
98
- end
99
-
100
- def rank_strategy(strategy, permit_unanchored:)
101
- segments = Segmenter.call(
102
- document,
103
- base_url: @url,
104
- strategy:,
105
- permit_unanchored:,
106
- minimum_selector_frequency:,
107
- use_top_selectors:,
108
- link_resolver:
109
- )
110
- engine.select_eligible(segments, limit: TOP_K)
76
+ @ranked_segments ||= if document
77
+ segments = Segmenter.call(document, base_url: @url, strategy: :list, link_resolver:,
78
+ minimum_selector_frequency:, use_top_selectors:)
79
+ engine.select_eligible(segments, limit: TOP_K)
80
+ else
81
+ []
82
+ end
111
83
  end
112
84
 
113
- def materialize(ranked, fallback_anchorless: false)
85
+ def materialize(ranked)
114
86
  ranked.filter_map do |entry|
115
- ::Html2rss::Html::SstArticleExtractor.call(entry, base_url: @url, scraper: self.class, fallback_anchorless:)
87
+ ::Html2rss::Html::SstArticleExtractor.call(entry, base_url: @url, scraper: self.class)
116
88
  end
117
89
  end
118
90
 
@@ -31,9 +31,7 @@ module Html2rss
31
31
 
32
32
  # @param parsed_body [Hash, Array, Object] parsed JSON document (or HTML when misrouted)
33
33
  # @param url [String, Html2rss::Url] base URL for relative link resolution
34
- # @param _opts [Hash] scraper-specific options
35
- # @option _opts [Object] :_reserved reserved for future scraper-specific options
36
- def initialize(parsed_body, url:, **_opts)
34
+ def initialize(parsed_body, url:)
37
35
  @parsed_body = parsed_body
38
36
  @url = url
39
37
  end