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.
- checksums.yaml +4 -4
- data/README.md +76 -2
- data/html2rss.gemspec +4 -1
- data/lib/html2rss/auto_source/cleanup.rb +62 -36
- data/lib/html2rss/auto_source/scraper/html.rb +68 -145
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +116 -0
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +63 -0
- data/lib/html2rss/auto_source/scraper/json_state/document_scanner.rb +179 -0
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +55 -0
- data/lib/html2rss/auto_source/scraper/json_state.rb +31 -401
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +2 -14
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +35 -30
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +40 -120
- data/lib/html2rss/auto_source/scraper/sitemap/parser.rb +161 -0
- data/lib/html2rss/auto_source/scraper/sitemap.rb +10 -7
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +6 -2
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +69 -0
- data/lib/html2rss/auto_source/scraper.rb +109 -53
- data/lib/html2rss/auto_source/segment.rb +29 -0
- data/lib/html2rss/auto_source/segmenter/cluster.rb +163 -0
- data/lib/html2rss/auto_source/segmenter/list.rb +103 -0
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +80 -0
- data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
- data/lib/html2rss/auto_source/segmenter.rb +95 -0
- data/lib/html2rss/auto_source.rb +79 -21
- data/lib/html2rss/capture.rb +315 -0
- data/lib/html2rss/cli.rb +72 -18
- data/lib/html2rss/config/auto_source_contract.rb +5 -1
- data/lib/html2rss/config/request_controls.rb +33 -0
- data/lib/html2rss/config/schema.rb +67 -26
- data/lib/html2rss/config/selectors_validator.rb +60 -21
- data/lib/html2rss/config/validator.rb +33 -35
- data/lib/html2rss/config.rb +6 -2
- data/lib/html2rss/error.rb +15 -3
- data/lib/html2rss/feed_builder/rss.rb +17 -5
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +16 -6
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +3 -43
- data/lib/html2rss/feed_pipeline.rb +11 -0
- data/lib/html2rss/feed_result.rb +1 -1
- data/lib/html2rss/hash_util.rb +17 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +10 -36
- data/lib/html2rss/html/article_extractor/date_extractor.rb +2 -7
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +5 -50
- data/lib/html2rss/html/article_extractor/image_extractor.rb +5 -16
- data/lib/html2rss/html/article_rules/category.rb +55 -0
- data/lib/html2rss/html/article_rules/date.rb +25 -0
- data/lib/html2rss/html/article_rules/enclosure.rb +72 -0
- data/lib/html2rss/html/article_rules/image.rb +109 -0
- data/lib/html2rss/html/article_rules.rb +10 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/escaped_attributes.rb +27 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +2 -12
- data/lib/html2rss/html/rendering/pdf_renderer.rb +2 -8
- data/lib/html2rss/html/rendering/video_renderer.rb +2 -12
- data/lib/html2rss/html/sst_article_extractor.rb +308 -0
- data/lib/html2rss/link_destination/destination_facts.rb +40 -0
- data/lib/html2rss/link_destination/noise_policy.rb +68 -0
- data/lib/html2rss/link_destination/path_classifier.rb +208 -0
- data/lib/html2rss/link_destination/text_classifier.rb +64 -0
- data/lib/html2rss/link_destination.rb +8 -0
- data/lib/html2rss/mcp/server.rb +577 -0
- data/lib/html2rss/mcp.rb +21 -0
- data/lib/html2rss/request_service/blocked_surface.rb +24 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +89 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +4 -2
- data/lib/html2rss/request_service/budget.rb +7 -35
- data/lib/html2rss/request_service/context.rb +0 -6
- data/lib/html2rss/request_service/faraday_strategy.rb +87 -3
- data/lib/html2rss/request_service/network_guard.rb +5 -3
- data/lib/html2rss/request_service/policy.rb +1 -1
- data/lib/html2rss/request_service/response.rb +15 -1
- data/lib/html2rss/request_service/strategy.rb +1 -2
- data/lib/html2rss/request_service.rb +4 -9
- data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
- data/lib/html2rss/scoring/container_assessor.rb +77 -0
- data/lib/html2rss/scoring/engine.rb +145 -0
- data/lib/html2rss/scoring/link_resolver.rb +84 -0
- data/lib/html2rss/scoring/observation.rb +53 -0
- data/lib/html2rss/scoring/ranked_segment.rb +33 -0
- data/lib/html2rss/scoring/score.rb +11 -0
- data/lib/html2rss/scoring.rb +8 -0
- data/lib/html2rss/selectors/extractors/attribute.rb +15 -0
- data/lib/html2rss/selectors/extractors/href.rb +12 -0
- data/lib/html2rss/selectors/extractors/html.rb +12 -0
- data/lib/html2rss/selectors/extractors/static.rb +14 -0
- data/lib/html2rss/selectors/extractors/text.rb +11 -0
- data/lib/html2rss/selectors/post_processors/gsub.rb +18 -0
- data/lib/html2rss/selectors/post_processors/html_to_markdown.rb +11 -0
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +12 -0
- data/lib/html2rss/selectors/post_processors/parse_time.rb +11 -0
- data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +23 -1
- data/lib/html2rss/selectors/post_processors/substring.rb +21 -0
- data/lib/html2rss/selectors/post_processors/template.rb +20 -0
- data/lib/html2rss/selectors/schema_doc.rb +99 -0
- data/lib/html2rss/selectors.rb +0 -20
- data/lib/html2rss/sst/attrs.rb +91 -0
- data/lib/html2rss/sst/document.rb +23 -0
- data/lib/html2rss/sst/index.rb +112 -0
- data/lib/html2rss/sst/node.rb +147 -0
- data/lib/html2rss/sst/normalizer.rb +171 -0
- data/lib/html2rss/sst/tags.rb +26 -0
- data/lib/html2rss/sst/text.rb +81 -0
- data/lib/html2rss/sst.rb +8 -0
- data/lib/html2rss/url.rb +11 -7
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss.rb +57 -26
- data/schema/html2rss-config.schema.json +428 -85
- metadata +93 -27
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +0 -80
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +0 -86
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +0 -119
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +0 -94
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +0 -219
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +0 -71
- data/lib/html2rss/auto_source/discovery/sitemap.rb +0 -159
- data/lib/html2rss/auto_source/discovery.rb +0 -14
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +0 -28
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +0 -106
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +0 -80
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +0 -42
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +0 -38
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +0 -221
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +0 -66
- data/lib/html2rss/auto_source/link_heuristics.rb +0 -139
- data/lib/html2rss/request_service/browserless_strategy.rb +0 -132
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +0 -148
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +0 -86
- 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
|
|
@@ -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]
|