html2rss 0.24.0 → 0.26.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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +76 -2
  3. data/html2rss.gemspec +4 -1
  4. data/lib/html2rss/auto_source/cleanup.rb +62 -36
  5. data/lib/html2rss/auto_source/scraper/html.rb +68 -145
  6. data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
  7. data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
  8. data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
  9. data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
  10. data/lib/html2rss/auto_source/scraper/json_state.rb +31 -401
  11. data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
  12. data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
  13. data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
  14. data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
  15. data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
  16. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
  17. data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
  18. data/lib/html2rss/auto_source/scraper.rb +109 -53
  19. data/lib/html2rss/auto_source/segment.rb +29 -0
  20. data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
  21. data/lib/html2rss/auto_source/segmenter/list.rb +103 -0
  22. data/lib/html2rss/auto_source/segmenter/primary_link.rb +80 -0
  23. data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
  24. data/lib/html2rss/auto_source/segmenter.rb +95 -0
  25. data/lib/html2rss/auto_source.rb +79 -21
  26. data/lib/html2rss/capture.rb +315 -0
  27. data/lib/html2rss/cli.rb +72 -18
  28. data/lib/html2rss/config/auto_source_contract.rb +5 -1
  29. data/lib/html2rss/config/request_controls.rb +33 -0
  30. data/lib/html2rss/config/schema.rb +67 -26
  31. data/lib/html2rss/config/selectors_validator.rb +60 -21
  32. data/lib/html2rss/config/validator.rb +33 -35
  33. data/lib/html2rss/config.rb +6 -2
  34. data/lib/html2rss/error.rb +15 -3
  35. data/lib/html2rss/feed_builder/rss.rb +17 -5
  36. data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
  37. data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
  38. data/lib/html2rss/feed_pipeline.rb +11 -0
  39. data/lib/html2rss/feed_result.rb +1 -1
  40. data/lib/html2rss/hash_util.rb +17 -0
  41. data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
  42. data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
  43. data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
  44. data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
  45. data/lib/html2rss/html/article_rules/category.rb +55 -0
  46. data/lib/html2rss/html/article_rules/date.rb +25 -0
  47. data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
  48. data/lib/html2rss/html/article_rules/image.rb +109 -0
  49. data/lib/html2rss/html/article_rules.rb +10 -0
  50. data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
  51. data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
  52. data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
  53. data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
  54. data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
  55. data/lib/html2rss/html/sst_article_extractor.rb +308 -0
  56. data/lib/html2rss/link_destination/destination_facts.rb +40 -0
  57. data/lib/html2rss/link_destination/noise_policy.rb +68 -0
  58. data/lib/html2rss/link_destination/path_classifier.rb +208 -0
  59. data/lib/html2rss/link_destination/text_classifier.rb +64 -0
  60. data/lib/html2rss/link_destination.rb +8 -0
  61. data/lib/html2rss/mcp/server.rb +577 -0
  62. data/lib/html2rss/mcp.rb +21 -0
  63. data/lib/html2rss/request_service/blocked_surface.rb +24 -1
  64. data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
  65. data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
  66. data/lib/html2rss/request_service/budget.rb +7 -35
  67. data/lib/html2rss/request_service/context.rb +0 -6
  68. data/lib/html2rss/request_service/faraday_strategy.rb +87 -3
  69. data/lib/html2rss/request_service/network_guard.rb +5 -3
  70. data/lib/html2rss/request_service/policy.rb +1 -1
  71. data/lib/html2rss/request_service/response.rb +15 -1
  72. data/lib/html2rss/request_service/strategy.rb +1 -2
  73. data/lib/html2rss/request_service.rb +4 -9
  74. data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
  75. data/lib/html2rss/scoring/container_assessor.rb +77 -0
  76. data/lib/html2rss/scoring/engine.rb +145 -0
  77. data/lib/html2rss/scoring/link_resolver.rb +84 -0
  78. data/lib/html2rss/scoring/observation.rb +53 -0
  79. data/lib/html2rss/scoring/ranked_segment.rb +33 -0
  80. data/lib/html2rss/scoring/score.rb +11 -0
  81. data/lib/html2rss/scoring.rb +8 -0
  82. data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
  83. data/lib/html2rss/selectors/extractors/href.rb +12 -0
  84. data/lib/html2rss/selectors/extractors/html.rb +12 -0
  85. data/lib/html2rss/selectors/extractors/static.rb +14 -0
  86. data/lib/html2rss/selectors/extractors/text.rb +11 -0
  87. data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
  88. data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
  89. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
  90. data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
  91. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
  92. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +23 -1
  93. data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
  94. data/lib/html2rss/selectors/post_processors/template.rb +20 -0
  95. data/lib/html2rss/selectors/schema_doc.rb +99 -0
  96. data/lib/html2rss/selectors.rb +0 -20
  97. data/lib/html2rss/sst/attrs.rb +91 -0
  98. data/lib/html2rss/sst/document.rb +23 -0
  99. data/lib/html2rss/sst/index.rb +112 -0
  100. data/lib/html2rss/sst/node.rb +147 -0
  101. data/lib/html2rss/sst/normalizer.rb +171 -0
  102. data/lib/html2rss/sst/tags.rb +26 -0
  103. data/lib/html2rss/sst/text.rb +81 -0
  104. data/lib/html2rss/sst.rb +8 -0
  105. data/lib/html2rss/url.rb +11 -7
  106. data/lib/html2rss/version.rb +1 -1
  107. data/lib/html2rss.rb +57 -26
  108. data/schema/html2rss-config.schema.json +428 -85
  109. metadata +93 -27
  110. data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
  111. data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
  112. data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
  113. data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
  114. data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
  115. data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
  116. data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
  117. data/lib/html2rss/auto_source/discovery.rb +0 -14
  118. data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
  119. data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
  120. data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
  121. data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
  122. data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
  123. data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
  124. data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
  125. data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
  126. data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
  127. data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
  128. data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
  129. data/lib/html2rss/request_service/puppet_commander.rb +0 -95
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Scoring
5
+ ##
6
+ # Observes an SST container and builds typed {Observation} score inputs.
7
+ class ContainerAssessor
8
+ # Microdata itemprop values treated as publish/update markers.
9
+ PUBLISH_ITEMPROPS = %w[datePublished dateModified].freeze
10
+
11
+ # @param text_classifier [LinkDestination::TextClassifier]
12
+ # @param content_token_regexp [Regexp]
13
+ # @param junk_token_regexp [Regexp]
14
+ def initialize(text_classifier: LinkDestination::TextClassifier.new,
15
+ content_token_regexp: LinkResolver::CONTENT_TOKEN_REGEXP,
16
+ junk_token_regexp: LinkResolver::JUNK_TOKEN_REGEXP)
17
+ @text_classifier = text_classifier
18
+ @content_token_regexp = content_token_regexp
19
+ @junk_token_regexp = junk_token_regexp
20
+ end
21
+
22
+ ##
23
+ # @param container [SST::Node]
24
+ # @param selected_anchor [SST::Node, nil]
25
+ # @param destination_facts [LinkDestination::DestinationFacts, nil]
26
+ # @return [Observation]
27
+ def call(container, selected_anchor, destination_facts:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
28
+ title = entry_title(container, selected_anchor)
29
+ tokens = "#{container.attrs.class_attr} #{container.attrs.id}"
30
+
31
+ Observation.new(
32
+ title_word_count: word_count(title),
33
+ path_length: destination_facts&.url&.path.to_s.length,
34
+ content_path: destination_facts&.content_path,
35
+ publish_marker: publish_marker?(container),
36
+ descriptive_context: descriptive_context?(container.visible_text, title),
37
+ article_container: container.name == :article,
38
+ content_tokens: tokens.match?(@content_token_regexp),
39
+ junk_tokens: tokens.match?(@junk_token_regexp),
40
+ utility_prefix_title: @text_classifier.utility_prefix?(title),
41
+ recommended_title: @text_classifier.recommended?(title),
42
+ utility_path: destination_facts&.utility_path,
43
+ strong_post_suffix: destination_facts&.strong_post_suffix,
44
+ shallow: destination_facts&.shallow,
45
+ high_confidence_junk_path: destination_facts&.high_confidence_junk_path,
46
+ high_confidence_utility_destination: destination_facts&.high_confidence_utility_destination,
47
+ selected_anchor_present: !selected_anchor.nil?
48
+ )
49
+ end
50
+
51
+ private
52
+
53
+ def publish_marker?(container)
54
+ !!container.find do |n|
55
+ n.name == :time ||
56
+ n.attrs.datetime ||
57
+ PUBLISH_ITEMPROPS.include?(n.attrs.itemprop.to_s)
58
+ end
59
+ end
60
+
61
+ def descriptive_context?(container_text, title)
62
+ snippet = container_text.to_s.sub(/\A#{Regexp.escape(title.to_s)}/i, '')
63
+ snippet.length > 30 && word_count(snippet) >= 8
64
+ end
65
+
66
+ def entry_title(container, selected_anchor)
67
+ heading = container.find(&:heading?)
68
+ source = heading || selected_anchor
69
+ source ? source.visible_text.to_s.strip : ''
70
+ end
71
+
72
+ def word_count(text)
73
+ text.to_s.scan(/\p{Alnum}+/).size
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Scoring
5
+ ##
6
+ # Ranks AutoSource::Segment instances with an explicit feature registry (no send).
7
+ class Engine
8
+ # Soft cap after score-floor filtering (lazy extraction guard; breadth > blind top-50).
9
+ TOP_K = 99
10
+ # Minimum composite score retained by {#rank_top} (precision floor).
11
+ SCORE_FLOOR = 0.0
12
+
13
+ FEATURE_IDS = %i[
14
+ title_word_count_ge3 title_word_count_ge7 path_length_gt6 content_path
15
+ publish_marker descriptive_context article_container content_tokens
16
+ non_content_utility_path utility_prefix_title_short shallow weak_container
17
+ recommended_title_non_content high_confidence_junk_path junk_tokens
18
+ heading_anchor heading_text_match meaningful_text content_like_destination
19
+ cluster_size cluster_avg_words cluster_heading cluster_time cluster_date
20
+ ].to_set.freeze
21
+ private_constant :FEATURE_IDS
22
+
23
+ # Quality feature triples: [feature_id, predicate, weight].
24
+ QUALITY_RULES = [
25
+ [:title_word_count_ge3, ->(o) { o.title_word_count >= 3 }, 40],
26
+ [:title_word_count_ge7, ->(o) { o.title_word_count >= 7 }, 15],
27
+ [:path_length_gt6, ->(o) { o.path_length > 6 }, 20],
28
+ [:content_path, lambda(&:content_path), 15],
29
+ [:publish_marker, lambda(&:publish_marker), 15],
30
+ [:descriptive_context, lambda(&:descriptive_context), 10],
31
+ [:article_container, lambda(&:article_container), 10],
32
+ [:content_tokens, lambda(&:content_tokens), 10]
33
+ ].freeze
34
+
35
+ # Junk feature triples: [feature_id, predicate, weight].
36
+ JUNK_RULES = [
37
+ [:non_content_utility_path, ->(o) { o.utility_path && !o.content_path && !o.strong_post_suffix }, 25],
38
+ [:utility_prefix_title_short, ->(o) { o.utility_prefix_title && o.title_word_count <= 6 }, 15],
39
+ [:shallow, lambda(&:shallow), 10],
40
+ [:weak_container, ->(o) { !o.publish_marker && !o.descriptive_context }, 10],
41
+ [:recommended_title_non_content, ->(o) { o.recommended_title && !o.content_path }, 10],
42
+ [:high_confidence_junk_path, lambda(&:high_confidence_junk_path), 5],
43
+ [:junk_tokens, lambda(&:junk_tokens), 15]
44
+ ].freeze
45
+
46
+ ##
47
+ # @param id [Symbol]
48
+ # @return [Symbol]
49
+ # @raise [ArgumentError] when id is not in the closed set
50
+ def self.assert_feature_id!(id)
51
+ raise ArgumentError, "unknown feature: #{id.inspect}" unless FEATURE_IDS.include?(id)
52
+
53
+ id
54
+ end
55
+
56
+ (QUALITY_RULES + JUNK_RULES).each { |feature_id, _, _| assert_feature_id!(feature_id) }
57
+
58
+ # @param link_resolver [LinkResolver]
59
+ def initialize(link_resolver:)
60
+ @link_resolver = link_resolver
61
+ @assessor = ContainerAssessor.new(text_classifier: link_resolver.text_classifier)
62
+ end
63
+
64
+ ##
65
+ # @param segments [Array<Html2rss::AutoSource::Segment>]
66
+ # @return [Array<RankedSegment>] sorted by composite desc, position asc; hard-junk dropped
67
+ def rank(segments)
68
+ segments.filter_map { |segment| rank_one(segment) }
69
+ .sort_by { |ranked| [-ranked.final_score, ranked.position] }
70
+ end
71
+
72
+ ##
73
+ # Filters hard-junk but preserves discovery order (list/cluster scrapers).
74
+ #
75
+ # @param segments [Array<Html2rss::AutoSource::Segment>]
76
+ # @param limit [Integer]
77
+ # @return [Array<RankedSegment>]
78
+ def select_eligible(segments, limit: TOP_K)
79
+ segments.filter_map { |segment| rank_one(segment, hard_junk_mode: :lenient) }
80
+ .sort_by(&:position)
81
+ .first(limit)
82
+ end
83
+
84
+ ##
85
+ # @param segments [Array<Html2rss::AutoSource::Segment>]
86
+ # @param limit [Integer]
87
+ # @return [Array<RankedSegment>] score-floor filtered, then capped
88
+ def rank_top(segments, limit: TOP_K)
89
+ rank(segments).select { |ranked| ranked.final_score >= SCORE_FLOOR }.first(limit)
90
+ end
91
+
92
+ private
93
+
94
+ def rank_one(segment, hard_junk_mode: :strict) # rubocop:disable Metrics/MethodLength
95
+ facts = segment.primary_link ? @link_resolver.destination_facts(segment.primary_link) : nil
96
+ obs = @assessor.call(segment.root_node, segment.primary_link, destination_facts: facts)
97
+ return if obs.hard_junk?(mode: hard_junk_mode)
98
+
99
+ quality, quality_parts = apply_rules(QUALITY_RULES, obs)
100
+ junk, junk_parts = apply_rules(JUNK_RULES, obs)
101
+ ranked(
102
+ segment:,
103
+ score: build_score(
104
+ composite: quality - junk,
105
+ quality:,
106
+ junk:,
107
+ breakdown: quality_parts.merge(junk_parts)
108
+ )
109
+ )
110
+ end
111
+
112
+ def apply_rules(rules, obs)
113
+ parts = {}
114
+ rules.each do |feature_id, predicate, weight|
115
+ parts[feature_id] = weight if predicate.call(obs)
116
+ end
117
+ [parts.values.sum, parts]
118
+ end
119
+
120
+ def build_score(composite:, quality: nil, junk: nil, breakdown: nil)
121
+ raise ArgumentError, 'composite must be Numeric' unless composite.is_a?(Numeric)
122
+
123
+ Score.new(
124
+ composite: composite.to_f,
125
+ quality: (quality.nil? ? composite : quality).to_f,
126
+ junk: (junk || 0).to_f,
127
+ breakdown: normalize_breakdown(breakdown)
128
+ )
129
+ end
130
+
131
+ def normalize_breakdown(breakdown)
132
+ return Score::EMPTY_BREAKDOWN if breakdown.nil?
133
+
134
+ breakdown.transform_keys { |id| self.class.assert_feature_id!(id) }.freeze
135
+ end
136
+
137
+ def ranked(segment:, score:)
138
+ raise ArgumentError, 'segment must be AutoSource::Segment' unless segment.is_a?(Html2rss::AutoSource::Segment)
139
+ raise ArgumentError, 'score must be Scoring::Score' unless score.is_a?(Score)
140
+
141
+ RankedSegment.new(segment:, score:)
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Scoring
5
+ ##
6
+ # Resolves href strings into memoized LinkDestination::DestinationFacts for one page base URL.
7
+ class LinkResolver
8
+ # Captures the href portion before a fragment for memoization keys.
9
+ HREF_BASE_PATTERN = /\A([^#]*)/
10
+
11
+ # Matches content-like tokens in class/id strings (from PathClassifier vocabulary).
12
+ CONTENT_TOKEN_REGEXP = begin
13
+ words = LinkDestination::PathClassifier::SEGMENT_SETS.fetch(:content)
14
+ /(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
15
+ end.freeze
16
+
17
+ # Matches utility/junk tokens in class/id strings (from PathClassifier vocabulary).
18
+ JUNK_TOKEN_REGEXP = begin
19
+ words = LinkDestination::PathClassifier::SEGMENT_SETS.fetch(:utility)
20
+ /(?:^|\s|[-_])(#{Regexp.union(words.to_a).source})(?:\s|[-_]|$)/i
21
+ end.freeze
22
+
23
+ # @param base_url [String, Html2rss::Url]
24
+ def initialize(base_url)
25
+ @base_url = base_url
26
+ @by_href = {}
27
+ @by_node = {}.compare_by_identity
28
+ @text_classifier = LinkDestination::TextClassifier.new
29
+ end
30
+
31
+ # @return [LinkDestination::TextClassifier]
32
+ attr_reader :text_classifier
33
+
34
+ ##
35
+ # @param node_or_href [SST::Node, String, #to_s]
36
+ # @return [LinkDestination::DestinationFacts, nil]
37
+ def destination_facts(node_or_href)
38
+ return @by_node[node_or_href] if node_or_href.is_a?(SST::Node) && @by_node.key?(node_or_href)
39
+
40
+ href = extract_href(node_or_href)
41
+ return unless href
42
+
43
+ facts = (@by_href[href] ||= build_facts(href))
44
+ @by_node[node_or_href] = facts if node_or_href.is_a?(SST::Node)
45
+ facts
46
+ rescue ArgumentError
47
+ nil
48
+ end
49
+
50
+ ##
51
+ # @param text [String, #to_s]
52
+ # @return [Boolean]
53
+ def utility_text?(text) = @text_classifier.utility?(text)
54
+
55
+ ##
56
+ # @param text [String, #to_s]
57
+ # @return [Boolean]
58
+ def utility_prefix_text?(text) = @text_classifier.utility_prefix?(text)
59
+
60
+ ##
61
+ # @param text [String, #to_s]
62
+ # @return [Boolean]
63
+ def recommended_text?(text) = @text_classifier.recommended?(text)
64
+
65
+ private
66
+
67
+ def extract_href(node_or_href)
68
+ raw = case node_or_href
69
+ when SST::Node then node_or_href.attrs.href
70
+ else node_or_href
71
+ end
72
+ return unless raw
73
+
74
+ base = raw.to_s[HREF_BASE_PATTERN, 1].to_s.strip
75
+ base unless base.empty?
76
+ end
77
+
78
+ def build_facts(href)
79
+ url = Html2rss::Url.from_relative(href, @base_url)
80
+ LinkDestination::DestinationFacts.build(url)
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Scoring
5
+ ##
6
+ # Typed container signals produced by {ContainerAssessor} for {Engine} rules.
7
+ Observation = Data.define(
8
+ :title_word_count,
9
+ :path_length,
10
+ :content_path,
11
+ :publish_marker,
12
+ :descriptive_context,
13
+ :article_container,
14
+ :content_tokens,
15
+ :junk_tokens,
16
+ :utility_prefix_title,
17
+ :recommended_title,
18
+ :utility_path,
19
+ :strong_post_suffix,
20
+ :shallow,
21
+ :high_confidence_junk_path,
22
+ :high_confidence_utility_destination,
23
+ :selected_anchor_present
24
+ ) do
25
+ ##
26
+ # @return [Boolean] true when fewer than two positive content signals are present
27
+ def weak_signals?
28
+ [article_container, publish_marker, descriptive_context, content_path].count(&:itself) < 2
29
+ end
30
+
31
+ ##
32
+ # @param mode [Symbol] +:strict+ for rank/rank_top; +:lenient+ for select_eligible
33
+ # @return [Boolean] true when the observation should be dropped before ranking
34
+ def hard_junk?(mode: :strict)
35
+ case mode
36
+ when :strict
37
+ high_confidence_junk_path || chrome_title_junk?
38
+ when :lenient
39
+ (high_confidence_junk_path && weak_signals?) || chrome_title_junk?
40
+ else
41
+ raise ArgumentError, "unknown hard_junk mode: #{mode.inspect}"
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def chrome_title_junk? # rubocop:disable Metrics/CyclomaticComplexity
48
+ (selected_anchor_present && recommended_title && shallow && weak_signals?) ||
49
+ (selected_anchor_present && utility_prefix_title && high_confidence_utility_destination && weak_signals?)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Scoring
5
+ ##
6
+ # A segment paired with its composite score.
7
+ RankedSegment = Data.define(:segment, :score) do
8
+ ##
9
+ # @return [SST::Node]
10
+ def root_node = segment.root_node
11
+
12
+ ##
13
+ # @return [SST::Node, nil]
14
+ def primary_link = segment.primary_link
15
+
16
+ ##
17
+ # @return [Float]
18
+ def final_score = score.composite
19
+
20
+ ##
21
+ # @return [Float]
22
+ def quality_score = score.quality
23
+
24
+ ##
25
+ # @return [Float]
26
+ def junk_score = score.junk
27
+
28
+ ##
29
+ # @return [Integer]
30
+ def position = segment.position
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ module Scoring
5
+ ##
6
+ # Composite score with quality/junk split and optional typed feature breakdown.
7
+ Score = Data.define(:composite, :quality, :junk, :breakdown)
8
+ # Shared empty feature breakdown for scores without per-feature tallies.
9
+ Score::EMPTY_BREAKDOWN = {}.freeze
10
+ end
11
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Html2rss
4
+ ##
5
+ # Declarative feature scoring for AutoSource SST segments.
6
+ module Scoring
7
+ end
8
+ end
@@ -24,9 +24,24 @@ module Html2rss
24
24
  # In case you're extracting a date or a time, consider parsing it
25
25
  # during post processing with {PostProcessors::ParseTime}.
26
26
  class Attribute
27
+ # Config-facing option types (excluding shared selector fields supplied at runtime).
28
+ OPTION_TYPES = { attribute: String }.freeze
29
+
27
30
  # The available options for the attribute extractor.
28
31
  Options = Struct.new('AttributeOptions', :selector, :attribute, keyword_init: true)
29
32
 
33
+ # JSON Schema description exported via +schema_doc+.
34
+ DESCRIPTION = 'Return the value of an HTML attribute on the selected element. ' \
35
+ 'Requires sibling selector option `attribute` (attribute name).'
36
+
37
+ # Example extractor name values for JSON Schema +examples+.
38
+ EXAMPLES = [
39
+ 'attribute'
40
+ ].freeze
41
+
42
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
43
+ def self.schema_doc = SchemaDoc.for_extractor(name: :attribute, klass: self)
44
+
30
45
  ##
31
46
  # Initializes the Attribute extractor.
32
47
  #
@@ -27,6 +27,18 @@ module Html2rss
27
27
  # The available options for the href (attribute) extractor.
28
28
  Options = Struct.new('HrefOptions', :selector, :channel, keyword_init: true)
29
29
 
30
+ # JSON Schema description exported via +schema_doc+.
31
+ DESCRIPTION = 'Return the absolute URL from the selected element\'s `href` attribute ' \
32
+ '(relative hrefs are resolved against the channel URL).'
33
+
34
+ # Example extractor name values for JSON Schema +examples+.
35
+ EXAMPLES = [
36
+ 'href'
37
+ ].freeze
38
+
39
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
40
+ def self.schema_doc = SchemaDoc.for_extractor(name: :href, klass: self)
41
+
30
42
  ##
31
43
  # Initializes the Href extractor.
32
44
  #
@@ -26,6 +26,18 @@ module Html2rss
26
26
  # The available options for the html extractor.
27
27
  Options = Struct.new('HtmlOptions', :selector, keyword_init: true)
28
28
 
29
+ # JSON Schema description exported via +schema_doc+.
30
+ DESCRIPTION = 'Return the outer HTML of the selected element. ' \
31
+ 'Sanitize during post-processing (e.g. `sanitize_html`).'
32
+
33
+ # Example extractor name values for JSON Schema +examples+.
34
+ EXAMPLES = [
35
+ 'html'
36
+ ].freeze
37
+
38
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
39
+ def self.schema_doc = SchemaDoc.for_extractor(name: :html, klass: self)
40
+
29
41
  ##
30
42
  # Initializes the Html extractor.
31
43
  #
@@ -16,9 +16,23 @@ module Html2rss
16
16
  # Would return:
17
17
  # 'Foobar'
18
18
  class Static
19
+ # Config-facing option types (excluding shared selector fields supplied at runtime).
20
+ OPTION_TYPES = { static: String }.freeze
21
+
19
22
  # The available option for the static extractor.
20
23
  Options = Struct.new('StaticOptions', :static, keyword_init: true)
21
24
 
25
+ # JSON Schema description exported via +schema_doc+.
26
+ DESCRIPTION = 'Return a fixed value from sibling selector option `static` (no DOM read).'
27
+
28
+ # Example extractor name values for JSON Schema +examples+.
29
+ EXAMPLES = [
30
+ 'static'
31
+ ].freeze
32
+
33
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
34
+ def self.schema_doc = SchemaDoc.for_extractor(name: :static, klass: self)
35
+
22
36
  ##
23
37
  # Initializes the Static extractor.
24
38
  #
@@ -24,6 +24,17 @@ module Html2rss
24
24
  # The available options for the text extractor.
25
25
  Options = Struct.new('TextOptions', :selector, keyword_init: true)
26
26
 
27
+ # JSON Schema description exported via +schema_doc+.
28
+ DESCRIPTION = 'Return collapsed visible text of the selected element (default extractor).'
29
+
30
+ # Example extractor name values for JSON Schema +examples+.
31
+ EXAMPLES = [
32
+ 'text'
33
+ ].freeze
34
+
35
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this extractor name
36
+ def self.schema_doc = SchemaDoc.for_extractor(name: :text, klass: self)
37
+
27
38
  ##
28
39
  # Initializes the Text extractor.
29
40
  #
@@ -29,6 +29,24 @@ module Html2rss
29
29
  #
30
30
  # See the doc on [String#gsub](https://ruby-doc.org/core/String.html#method-i-gsub) for more info.
31
31
  class Gsub < Base
32
+ # Required config field types (validator introspection via +Options+).
33
+ OPTION_TYPES = { pattern: String, replacement: String }.freeze
34
+
35
+ # Config fields required by this post-processor (validator / schema introspection).
36
+ Options = Struct.new(*OPTION_TYPES.keys, keyword_init: true)
37
+
38
+ # JSON Schema description exported via +schema_doc+.
39
+ DESCRIPTION = 'Replace matches of `pattern` in the extracted string with `replacement` ' \
40
+ '(Ruby String#gsub; pattern may be a regexp-like string).'
41
+
42
+ # Example post-process objects for JSON Schema +examples+.
43
+ EXAMPLES = [
44
+ { 'name' => 'gsub', 'pattern' => 'boo', 'replacement' => 'baz' }
45
+ ].freeze
46
+
47
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
48
+ def self.schema_doc = SchemaDoc.for_post_processor(name: :gsub, klass: self)
49
+
32
50
  # @param value [String] extracted selector value
33
51
  # @param context [Selectors::Context] post-processor context
34
52
  # @return [void]
@@ -28,6 +28,17 @@ module Html2rss
28
28
  # Would return:
29
29
  # 'Lorem **ipsum** dolor'
30
30
  class HtmlToMarkdown < Base
31
+ # JSON Schema description exported via +schema_doc+.
32
+ DESCRIPTION = 'Sanitize HTML then convert it to Markdown (via ReverseMarkdown).'
33
+
34
+ # Example post-process objects for JSON Schema +examples+.
35
+ EXAMPLES = [
36
+ { 'name' => 'html_to_markdown' }
37
+ ].freeze
38
+
39
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
40
+ def self.schema_doc = SchemaDoc.for_post_processor(name: :html_to_markdown, klass: self)
41
+
31
42
  # @param value [String] extracted selector value
32
43
  # @param context [Selectors::Context] post-processor context
33
44
  # @return [void]
@@ -33,6 +33,18 @@ module Html2rss
33
33
  #
34
34
  # <p>Price: 12.34</p>
35
35
  class MarkdownToHtml < Base
36
+ # JSON Schema description exported via +schema_doc+.
37
+ DESCRIPTION = 'Convert Markdown to HTML (Kramdown) and sanitize the result. ' \
38
+ 'Often chained after `template`.'
39
+
40
+ # Example post-process objects for JSON Schema +examples+.
41
+ EXAMPLES = [
42
+ { 'name' => 'markdown_to_html' }
43
+ ].freeze
44
+
45
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
46
+ def self.schema_doc = SchemaDoc.for_post_processor(name: :markdown_to_html, klass: self)
47
+
36
48
  # @param value [String] extracted selector value
37
49
  # @param context [Selectors::Context] post-processor context
38
50
  # @return [void]
@@ -27,6 +27,17 @@ module Html2rss
27
27
  #
28
28
  # It uses `Time.parse`.
29
29
  class ParseTime < Base
30
+ # JSON Schema description exported via +schema_doc+.
31
+ DESCRIPTION = 'Parse a time string with Time.parse and return RFC822, using the channel `time_zone`.'
32
+
33
+ # Example post-process objects for JSON Schema +examples+.
34
+ EXAMPLES = [
35
+ { 'name' => 'parse_time' }
36
+ ].freeze
37
+
38
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
39
+ def self.schema_doc = SchemaDoc.for_post_processor(name: :parse_time, klass: self)
40
+
30
41
  # @param value [String] extracted selector value
31
42
  # @param context [Selectors::Context] post-processor context
32
43
  # @return [void]
@@ -23,6 +23,17 @@ module Html2rss
23
23
  # Would return:
24
24
  # 'http://why-not-use-a-link.uh'
25
25
  class ParseUri < Base
26
+ # JSON Schema description exported via +schema_doc+.
27
+ DESCRIPTION = 'Normalize a URL string; resolve relative URLs against the channel URL.'
28
+
29
+ # Example post-process objects for JSON Schema +examples+.
30
+ EXAMPLES = [
31
+ { 'name' => 'parse_uri' }
32
+ ].freeze
33
+
34
+ # @return [Hash{Symbol => Object}] JSON Schema fragment for this post-processor
35
+ def self.schema_doc = SchemaDoc.for_post_processor(name: :parse_uri, klass: self)
36
+
26
37
  # @param value [String] extracted selector value
27
38
  # @param _context [Selectors::Context] post-processor context
28
39
  # @return [void]