html2rss 0.24.0 → 0.25.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/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 +0 -379
- 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.rb +81 -44
- 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 +101 -0
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +81 -0
- data/lib/html2rss/auto_source/segmenter/semantic.rb +104 -0
- data/lib/html2rss/auto_source/segmenter.rb +66 -0
- data/lib/html2rss/auto_source.rb +74 -21
- data/lib/html2rss/cli.rb +8 -13
- data/lib/html2rss/config/auto_source_contract.rb +2 -0
- data/lib/html2rss/config/request_controls.rb +33 -0
- data/lib/html2rss/config/validator.rb +15 -8
- data/lib/html2rss/config.rb +6 -2
- data/lib/html2rss/feed_builder/rss.rb +17 -5
- data/lib/html2rss/feed_pipeline.rb +4 -0
- data/lib/html2rss/feed_result.rb +1 -1
- 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 +279 -0
- data/lib/html2rss/link_destination/destination_facts.rb +40 -0
- data/lib/html2rss/link_destination/noise_policy.rb +79 -0
- data/lib/html2rss/link_destination/path_classifier.rb +205 -0
- data/lib/html2rss/link_destination/text_classifier.rb +64 -0
- data/lib/html2rss/link_destination.rb +8 -0
- data/lib/html2rss/request_service/faraday_strategy.rb +42 -1
- data/lib/html2rss/request_service/network_guard.rb +5 -3
- data/lib/html2rss/scoring/anchor_score.rb +34 -0
- data/lib/html2rss/scoring/cluster_scorer.rb +87 -0
- data/lib/html2rss/scoring/container_assessor.rb +83 -0
- data/lib/html2rss/scoring/engine.rb +101 -0
- data/lib/html2rss/scoring/link_resolver.rb +72 -0
- data/lib/html2rss/scoring/observation.rb +53 -0
- data/lib/html2rss/scoring/ranked_segment.rb +45 -0
- data/lib/html2rss/scoring/score.rb +30 -0
- data/lib/html2rss/scoring.rb +33 -0
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +10 -1
- 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/version.rb +1 -1
- data/lib/html2rss.rb +26 -26
- data/schema/html2rss-config.schema.json +20 -0
- metadata +42 -18
- 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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
module SST
|
|
7
|
+
##
|
|
8
|
+
# Sole Nokogiri consumer on the heuristic auto-source path. Builds an
|
|
9
|
+
# immutable SST::Document with parent/depth/chrome indices.
|
|
10
|
+
class Normalizer # rubocop:disable Metrics/ClassLength
|
|
11
|
+
# Hard ceiling for SST node allocations; beyond this we degrade.
|
|
12
|
+
MAX_NODES = 5_000
|
|
13
|
+
|
|
14
|
+
# Tags stripped entirely from the SST.
|
|
15
|
+
STRIPPED_TAGS = Set['script', 'style', 'noscript', 'iframe', 'svg', 'template'].freeze
|
|
16
|
+
|
|
17
|
+
# Kept when MAX_NODES forces semantic-tag-only degrade.
|
|
18
|
+
SEMANTIC_DEGRADE_TAGS = Set['html', 'body', 'article', 'section', 'li', 'tr', 'div', 'a',
|
|
19
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'time', 'img', 'p',
|
|
20
|
+
'ul', 'ol', 'main'].freeze
|
|
21
|
+
|
|
22
|
+
# Attribute names preserved in Attrs#raw for extraction.
|
|
23
|
+
RAW_ATTR_KEEP = /
|
|
24
|
+
\A(
|
|
25
|
+
data- |
|
|
26
|
+
aria- |
|
|
27
|
+
class |
|
|
28
|
+
id |
|
|
29
|
+
href |
|
|
30
|
+
src |
|
|
31
|
+
srcset |
|
|
32
|
+
style |
|
|
33
|
+
datetime |
|
|
34
|
+
itemprop |
|
|
35
|
+
type |
|
|
36
|
+
category |
|
|
37
|
+
categories |
|
|
38
|
+
tag |
|
|
39
|
+
tags |
|
|
40
|
+
topic |
|
|
41
|
+
topics |
|
|
42
|
+
section |
|
|
43
|
+
sections |
|
|
44
|
+
label |
|
|
45
|
+
labels |
|
|
46
|
+
theme |
|
|
47
|
+
themes |
|
|
48
|
+
subject |
|
|
49
|
+
subjects
|
|
50
|
+
)
|
|
51
|
+
/xi
|
|
52
|
+
|
|
53
|
+
class << self
|
|
54
|
+
##
|
|
55
|
+
# @param input [String, Nokogiri::HTML::Document, Nokogiri::XML::Node]
|
|
56
|
+
# HTML string or already-parsed node (String is parsed once here)
|
|
57
|
+
# @return [Document]
|
|
58
|
+
# @raise [ArgumentError] when input is nil or unsupported
|
|
59
|
+
def call(input)
|
|
60
|
+
raise ArgumentError, 'input is required' if input.nil?
|
|
61
|
+
|
|
62
|
+
new(coerce(input)).call
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# @param input [String, Nokogiri::XML::Node]
|
|
68
|
+
# @return [Nokogiri::XML::Node]
|
|
69
|
+
def coerce(input)
|
|
70
|
+
case input
|
|
71
|
+
when String then Nokogiri::HTML(input)
|
|
72
|
+
when Nokogiri::XML::Node then input
|
|
73
|
+
else
|
|
74
|
+
raise ArgumentError, "expected String or Nokogiri::XML::Node, got #{input.class}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param parsed_body [Nokogiri::HTML::Document, Nokogiri::XML::Node]
|
|
80
|
+
def initialize(parsed_body)
|
|
81
|
+
@parsed_body = parsed_body
|
|
82
|
+
@node_count = 0
|
|
83
|
+
@degraded = false
|
|
84
|
+
@parents = {}.compare_by_identity
|
|
85
|
+
@depths = {}.compare_by_identity
|
|
86
|
+
@ignored_chrome = {}.compare_by_identity
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
##
|
|
90
|
+
# @return [Document]
|
|
91
|
+
def call
|
|
92
|
+
root_nk = @parsed_body.respond_to?(:root) ? (@parsed_body.at_css('html') || @parsed_body.root) : @parsed_body
|
|
93
|
+
root = normalize_element(root_nk, parent: nil, depth: 0, path: '', chrome: false)
|
|
94
|
+
raise ArgumentError, 'SST Normalizer produced an empty tree' unless root
|
|
95
|
+
|
|
96
|
+
index = Index.new(root:, parents: @parents, depths: @depths, ignored_chrome: @ignored_chrome)
|
|
97
|
+
Document.build(root:, index:, degraded: @degraded, node_count: @node_count)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
103
|
+
def normalize_element(nk_node, parent:, depth:, path:, chrome:)
|
|
104
|
+
return unless nk_node.respond_to?(:name)
|
|
105
|
+
return if nk_node.text? || nk_node.comment? || nk_node.cdata?
|
|
106
|
+
|
|
107
|
+
tag = nk_node.name.to_s.downcase
|
|
108
|
+
return if STRIPPED_TAGS.include?(tag)
|
|
109
|
+
return if @degraded && !SEMANTIC_DEGRADE_TAGS.include?(tag)
|
|
110
|
+
|
|
111
|
+
if @node_count >= MAX_NODES && !@degraded
|
|
112
|
+
@degraded = true
|
|
113
|
+
Html2rss::Log.warn("sst.normalizer MAX_NODES=#{MAX_NODES} breached; degrading to semantic-tag-only")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
return if @degraded && !SEMANTIC_DEGRADE_TAGS.include?(tag)
|
|
117
|
+
|
|
118
|
+
@node_count += 1
|
|
119
|
+
tag_path = path.empty? ? "/#{tag}" : "#{path}/#{tag}"
|
|
120
|
+
attrs = extract_attrs(nk_node)
|
|
121
|
+
own_text = direct_text(nk_node)
|
|
122
|
+
chrome_here = chrome || Tags::IGNORED_CONTAINER_NAMES.include?(tag.to_sym)
|
|
123
|
+
|
|
124
|
+
children = nk_node.children.filter_map do |child|
|
|
125
|
+
normalize_element(child, parent: :pending, depth: depth + 1, path: tag_path, chrome: chrome_here)
|
|
126
|
+
end.freeze
|
|
127
|
+
|
|
128
|
+
node = Node.build(name: tag, attrs:, own_text:, children:, tag_path:)
|
|
129
|
+
@parents[node] = parent == :pending ? nil : parent
|
|
130
|
+
@depths[node] = depth
|
|
131
|
+
@ignored_chrome[node] = chrome_here
|
|
132
|
+
|
|
133
|
+
# Fix parent pointers now that node exists.
|
|
134
|
+
children.each { |child| @parents[child] = node }
|
|
135
|
+
|
|
136
|
+
node
|
|
137
|
+
end
|
|
138
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
139
|
+
|
|
140
|
+
def extract_attrs(nk_node) # rubocop:disable Metrics/MethodLength
|
|
141
|
+
Attrs.build(
|
|
142
|
+
href: nk_node['href'],
|
|
143
|
+
src: nk_node['src'],
|
|
144
|
+
id: nk_node['id'],
|
|
145
|
+
class_names: nk_node['class'].to_s.split(/\s+/).reject(&:empty?),
|
|
146
|
+
datetime: nk_node['datetime'],
|
|
147
|
+
itemprop: nk_node['itemprop'],
|
|
148
|
+
style: nk_node['style'],
|
|
149
|
+
srcset: nk_node['srcset'],
|
|
150
|
+
type: nk_node['type'],
|
|
151
|
+
raw: raw_attrs(nk_node)
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def raw_attrs(nk_node)
|
|
156
|
+
typed = %w[href src id class datetime itemprop style srcset type].to_set
|
|
157
|
+
nk_node.attribute_nodes.each_with_object({}) do |attr, raw|
|
|
158
|
+
name = attr.name.to_s
|
|
159
|
+
next if typed.include?(name)
|
|
160
|
+
next unless name.match?(RAW_ATTR_KEEP)
|
|
161
|
+
|
|
162
|
+
raw[name] = attr.value.to_s
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def direct_text(nk_node)
|
|
167
|
+
nk_node.children.select(&:text?).map(&:text).join
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module SST
|
|
5
|
+
##
|
|
6
|
+
# Shared tag-name and href constants for SST nodes.
|
|
7
|
+
module Tags
|
|
8
|
+
# Heading element names.
|
|
9
|
+
HEADING_NAMES = %i[h1 h2 h3 h4 h5 h6].to_set.freeze
|
|
10
|
+
# Block-level names that contribute structural newlines in text.
|
|
11
|
+
BLOCK_NAMES = %i[p div li ul ol h1 h2 h3 h4 h5 h6 tr br].to_set.freeze
|
|
12
|
+
# Names skipped when collecting visible text.
|
|
13
|
+
INVISIBLE_NAMES = %i[svg script noscript style template].to_set.freeze
|
|
14
|
+
# Landmark names treated as utility chrome.
|
|
15
|
+
UTILITY_LANDMARK_NAMES = %i[nav aside footer menu].to_set.freeze
|
|
16
|
+
# Container names ignored as article roots.
|
|
17
|
+
IGNORED_CONTAINER_NAMES = %i[nav footer header svg script style].to_set.freeze
|
|
18
|
+
# Names excluded from class/structure clustering.
|
|
19
|
+
CLUSTER_EXCLUDED_NAMES = %i[html body nav footer header svg script style].to_set.freeze
|
|
20
|
+
# Href prefixes that never count as content destinations.
|
|
21
|
+
SKIP_HREF_PREFIXES = ['#', 'javascript:', 'mailto:', 'tel:', 'file://', 'sms:', 'data:'].freeze
|
|
22
|
+
# Layout-ish names used when resolving nested wrapper groups.
|
|
23
|
+
LAYOUT_NAMES = %i[div section article li ul ol].to_set.freeze
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module SST
|
|
5
|
+
##
|
|
6
|
+
# Visible-text extraction for SST nodes (port of Navigator::TextExtractor).
|
|
7
|
+
module Text
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
##
|
|
11
|
+
# @param node [Node]
|
|
12
|
+
# @param separator [String]
|
|
13
|
+
# @param exclude [Array<Node>, nil]
|
|
14
|
+
# @return [String, nil]
|
|
15
|
+
def extract(node, separator: ' ', exclude: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
16
|
+
raise ArgumentError, 'node is required' unless node
|
|
17
|
+
|
|
18
|
+
excluded = exclude&.each_with_object({}.compare_by_identity) { |n, h| h[n] = true }
|
|
19
|
+
if node.children.empty?
|
|
20
|
+
text = node.own_text.to_s.gsub(/\s+/, ' ').strip
|
|
21
|
+
return text.empty? ? nil : text
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
parts = collect_parts(node, separator, excluded)
|
|
25
|
+
own = node.own_text.to_s.gsub(/\s+/, ' ').strip
|
|
26
|
+
parts = [own, *parts] unless own.empty?
|
|
27
|
+
return if parts.empty?
|
|
28
|
+
|
|
29
|
+
parts.join.squeeze(' ').strip
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def collect_parts(node, separator, excluded)
|
|
33
|
+
last_block = false
|
|
34
|
+
node.children.each_with_object([]) do |child, parts|
|
|
35
|
+
next if excluded&.[](child)
|
|
36
|
+
next unless visible_child?(child)
|
|
37
|
+
|
|
38
|
+
text, block = child_text_and_block(child, separator, excluded)
|
|
39
|
+
next if text.empty?
|
|
40
|
+
|
|
41
|
+
append_separator!(parts, separator, block, last_block)
|
|
42
|
+
parts << text
|
|
43
|
+
last_block = block
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
module_function :collect_parts
|
|
47
|
+
private_class_method :collect_parts
|
|
48
|
+
|
|
49
|
+
def child_text_and_block(child, separator, excluded)
|
|
50
|
+
text = if child.children.empty?
|
|
51
|
+
child.own_text.gsub(/\s+/, ' ').strip
|
|
52
|
+
else
|
|
53
|
+
extract(child, separator:, exclude: excluded&.keys).to_s.strip
|
|
54
|
+
end
|
|
55
|
+
text = "- #{text}" if child.name == :li && !text.empty?
|
|
56
|
+
[text, Tags::BLOCK_NAMES.include?(child.name)]
|
|
57
|
+
end
|
|
58
|
+
module_function :child_text_and_block
|
|
59
|
+
private_class_method :child_text_and_block
|
|
60
|
+
|
|
61
|
+
def append_separator!(parts, separator, is_block, last_was_block)
|
|
62
|
+
return if parts.empty?
|
|
63
|
+
|
|
64
|
+
parts << if is_block || last_was_block
|
|
65
|
+
(separator == ' ' ? "\n" : separator)
|
|
66
|
+
else
|
|
67
|
+
' '
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
module_function :append_separator!
|
|
71
|
+
private_class_method :append_separator!
|
|
72
|
+
|
|
73
|
+
def visible_child?(node)
|
|
74
|
+
!Tags::INVISIBLE_NAMES.include?(node.name) &&
|
|
75
|
+
!(node.name == :a && node.attrs.href&.start_with?('#'))
|
|
76
|
+
end
|
|
77
|
+
module_function :visible_child?
|
|
78
|
+
private_class_method :visible_child?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
data/lib/html2rss/sst.rb
ADDED
data/lib/html2rss/version.rb
CHANGED
data/lib/html2rss.rb
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
require 'zeitwerk'
|
|
4
4
|
|
|
5
5
|
loader = Zeitwerk::Loader.for_gem
|
|
6
|
-
loader.inflector.inflect(
|
|
6
|
+
loader.inflector.inflect(
|
|
7
|
+
'cli' => 'CLI',
|
|
8
|
+
'sst' => 'SST'
|
|
9
|
+
)
|
|
7
10
|
loader.setup
|
|
8
11
|
|
|
9
12
|
require 'logger'
|
|
@@ -67,7 +70,10 @@ module Html2rss
|
|
|
67
70
|
# rubocop:disable Metrics/ParameterLists
|
|
68
71
|
|
|
69
72
|
##
|
|
70
|
-
# Scrapes the provided URL and returns an RSS object.
|
|
73
|
+
# Scrapes the provided URL without hand-written selectors and returns an RSS object.
|
|
74
|
+
#
|
|
75
|
+
# Builds an auto_source config, then FeedPipeline runs structured scrapers and
|
|
76
|
+
# (when needed) the SST heuristic path — see docs/auto_source.md.
|
|
71
77
|
#
|
|
72
78
|
# @param url [String] source page URL
|
|
73
79
|
# @param strategy [Symbol] request strategy to use
|
|
@@ -75,18 +81,23 @@ module Html2rss
|
|
|
75
81
|
# @param max_redirects [Integer, nil] optional redirect limit override
|
|
76
82
|
# @param max_requests [Integer] optional request budget override (default: 4 for sitemap sub-fetches)
|
|
77
83
|
# @param local_file_path [String, nil] optional local HTML file path
|
|
84
|
+
# @param limit [Integer, nil] max articles to keep (default: {AutoSource::DEFAULT_LIMIT})
|
|
78
85
|
# @return [RSS::Rss] generated RSS feed
|
|
79
86
|
def self.auto_source(url,
|
|
80
87
|
strategy: :auto,
|
|
81
88
|
items_selector: nil,
|
|
82
89
|
max_redirects: nil,
|
|
83
90
|
max_requests: 4,
|
|
84
|
-
local_file_path: nil
|
|
85
|
-
|
|
91
|
+
local_file_path: nil,
|
|
92
|
+
limit: nil)
|
|
93
|
+
feed(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
|
|
94
|
+
local_file_path:, limit:))
|
|
86
95
|
end
|
|
87
96
|
|
|
88
97
|
##
|
|
89
|
-
# Scrapes the provided URL and returns a JSONFeed 1.1 hash.
|
|
98
|
+
# Scrapes the provided URL without hand-written selectors and returns a JSONFeed 1.1 hash.
|
|
99
|
+
#
|
|
100
|
+
# Same auto_source pipeline as {.auto_source}; see docs/auto_source.md.
|
|
90
101
|
#
|
|
91
102
|
# @param url [String] source page URL
|
|
92
103
|
# @param strategy [Symbol] request strategy to use
|
|
@@ -94,15 +105,17 @@ module Html2rss
|
|
|
94
105
|
# @param max_redirects [Integer, nil] optional redirect limit override
|
|
95
106
|
# @param max_requests [Integer] optional request budget override (default: 4 for sitemap sub-fetches)
|
|
96
107
|
# @param local_file_path [String, nil] optional local HTML file path
|
|
108
|
+
# @param limit [Integer, nil] max articles to keep (default: {AutoSource::DEFAULT_LIMIT})
|
|
97
109
|
# @return [Hash] JSONFeed-compliant hash
|
|
98
110
|
def self.auto_json_feed(url,
|
|
99
111
|
strategy: :auto,
|
|
100
112
|
items_selector: nil,
|
|
101
113
|
max_redirects: nil,
|
|
102
114
|
max_requests: 4,
|
|
103
|
-
local_file_path: nil
|
|
115
|
+
local_file_path: nil,
|
|
116
|
+
limit: nil)
|
|
104
117
|
json_feed(build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
|
|
105
|
-
local_file_path:))
|
|
118
|
+
local_file_path:, limit:))
|
|
106
119
|
end
|
|
107
120
|
|
|
108
121
|
# rubocop:enable Metrics/ParameterLists
|
|
@@ -154,11 +167,14 @@ module Html2rss
|
|
|
154
167
|
class << self
|
|
155
168
|
private
|
|
156
169
|
|
|
157
|
-
|
|
170
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
|
|
171
|
+
def build_auto_source_config(url:, strategy:, items_selector:, max_redirects:, max_requests:,
|
|
172
|
+
local_file_path: nil, limit: nil)
|
|
158
173
|
config = Config.auto_source_config(
|
|
159
174
|
url:,
|
|
160
175
|
items_selector:,
|
|
161
|
-
request_controls:
|
|
176
|
+
request_controls: Config::RequestControls.from_shortcut(strategy:, max_redirects:, max_requests:),
|
|
177
|
+
limit:
|
|
162
178
|
)
|
|
163
179
|
if local_file_path
|
|
164
180
|
config[:request] ||= {}
|
|
@@ -166,23 +182,7 @@ module Html2rss
|
|
|
166
182
|
end
|
|
167
183
|
config
|
|
168
184
|
end
|
|
169
|
-
|
|
170
|
-
def shortcut_request_controls(strategy:, max_redirects:, max_requests:)
|
|
171
|
-
Config::RequestControls.new(
|
|
172
|
-
strategy:,
|
|
173
|
-
max_redirects:,
|
|
174
|
-
max_requests:,
|
|
175
|
-
explicit_keys: explicit_request_control_keys(strategy:, max_redirects:, max_requests:)
|
|
176
|
-
)
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def explicit_request_control_keys(strategy:, max_redirects:, max_requests:)
|
|
180
|
-
keys = []
|
|
181
|
-
keys << :strategy unless strategy.nil? || strategy == Config.default_strategy_name
|
|
182
|
-
keys << :max_redirects unless max_redirects.nil?
|
|
183
|
-
keys << :max_requests unless max_requests.nil?
|
|
184
|
-
keys
|
|
185
|
-
end
|
|
185
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
|
|
186
186
|
end
|
|
187
187
|
|
|
188
188
|
logger.level = defaults.log_level if logger.respond_to?(:level=)
|
|
@@ -45,6 +45,18 @@
|
|
|
45
45
|
"null",
|
|
46
46
|
"string"
|
|
47
47
|
]
|
|
48
|
+
},
|
|
49
|
+
"author": {
|
|
50
|
+
"type": [
|
|
51
|
+
"null",
|
|
52
|
+
"string"
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
"image": {
|
|
56
|
+
"type": [
|
|
57
|
+
"null",
|
|
58
|
+
"string"
|
|
59
|
+
]
|
|
48
60
|
}
|
|
49
61
|
},
|
|
50
62
|
"required": [
|
|
@@ -94,6 +106,13 @@
|
|
|
94
106
|
"$schema": "http://json-schema.org/draft-06/schema#",
|
|
95
107
|
"type": "object",
|
|
96
108
|
"properties": {
|
|
109
|
+
"limit": {
|
|
110
|
+
"type": "integer",
|
|
111
|
+
"not": {
|
|
112
|
+
"type": "null"
|
|
113
|
+
},
|
|
114
|
+
"exclusiveMinimum": 0
|
|
115
|
+
},
|
|
97
116
|
"scraper": {
|
|
98
117
|
"type": "object",
|
|
99
118
|
"properties": {
|
|
@@ -269,6 +288,7 @@
|
|
|
269
288
|
},
|
|
270
289
|
"required": [],
|
|
271
290
|
"default": {
|
|
291
|
+
"limit": 25,
|
|
272
292
|
"scraper": {
|
|
273
293
|
"wordpress_api": {
|
|
274
294
|
"enabled": true
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: html2rss
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.25.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gil Desmarais
|
|
@@ -278,25 +278,13 @@ files:
|
|
|
278
278
|
- lib/html2rss/article/enclosure.rb
|
|
279
279
|
- lib/html2rss/auto_source.rb
|
|
280
280
|
- lib/html2rss/auto_source/cleanup.rb
|
|
281
|
-
- lib/html2rss/auto_source/discovery.rb
|
|
282
|
-
- lib/html2rss/auto_source/discovery/dom_clustering.rb
|
|
283
|
-
- lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb
|
|
284
|
-
- lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb
|
|
285
|
-
- lib/html2rss/auto_source/discovery/list_candidates.rb
|
|
286
|
-
- lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb
|
|
287
|
-
- lib/html2rss/auto_source/discovery/semantic_containers.rb
|
|
288
|
-
- lib/html2rss/auto_source/discovery/sitemap.rb
|
|
289
|
-
- lib/html2rss/auto_source/link_heuristics.rb
|
|
290
|
-
- lib/html2rss/auto_source/link_heuristics/anchor_signals.rb
|
|
291
|
-
- lib/html2rss/auto_source/link_heuristics/container_assessor.rb
|
|
292
|
-
- lib/html2rss/auto_source/link_heuristics/container_signals.rb
|
|
293
|
-
- lib/html2rss/auto_source/link_heuristics/destination_facts.rb
|
|
294
|
-
- lib/html2rss/auto_source/link_heuristics/href_extractor.rb
|
|
295
|
-
- lib/html2rss/auto_source/link_heuristics/path_classifier.rb
|
|
296
|
-
- lib/html2rss/auto_source/link_heuristics/text_classifier.rb
|
|
297
281
|
- lib/html2rss/auto_source/scraper.rb
|
|
298
282
|
- lib/html2rss/auto_source/scraper/html.rb
|
|
299
283
|
- lib/html2rss/auto_source/scraper/json_state.rb
|
|
284
|
+
- lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb
|
|
285
|
+
- lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb
|
|
286
|
+
- lib/html2rss/auto_source/scraper/json_state/document_scanner.rb
|
|
287
|
+
- lib/html2rss/auto_source/scraper/json_state/value_finder.rb
|
|
300
288
|
- lib/html2rss/auto_source/scraper/meta_oembed.rb
|
|
301
289
|
- lib/html2rss/auto_source/scraper/microdata.rb
|
|
302
290
|
- lib/html2rss/auto_source/scraper/microformats2.rb
|
|
@@ -307,10 +295,17 @@ files:
|
|
|
307
295
|
- lib/html2rss/auto_source/scraper/semantic_html.rb
|
|
308
296
|
- lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb
|
|
309
297
|
- lib/html2rss/auto_source/scraper/sitemap.rb
|
|
298
|
+
- lib/html2rss/auto_source/scraper/sitemap/parser.rb
|
|
310
299
|
- lib/html2rss/auto_source/scraper/wordpress_api.rb
|
|
311
300
|
- lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb
|
|
312
301
|
- lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb
|
|
313
302
|
- lib/html2rss/auto_source/scraper/wordpress_api/posts_endpoint.rb
|
|
303
|
+
- lib/html2rss/auto_source/segment.rb
|
|
304
|
+
- lib/html2rss/auto_source/segmenter.rb
|
|
305
|
+
- lib/html2rss/auto_source/segmenter/cluster.rb
|
|
306
|
+
- lib/html2rss/auto_source/segmenter/list.rb
|
|
307
|
+
- lib/html2rss/auto_source/segmenter/primary_link.rb
|
|
308
|
+
- lib/html2rss/auto_source/segmenter/semantic.rb
|
|
314
309
|
- lib/html2rss/channel.rb
|
|
315
310
|
- lib/html2rss/cli.rb
|
|
316
311
|
- lib/html2rss/config.rb
|
|
@@ -344,15 +339,27 @@ files:
|
|
|
344
339
|
- lib/html2rss/html/article_extractor/heading_extractor.rb
|
|
345
340
|
- lib/html2rss/html/article_extractor/id_generator.rb
|
|
346
341
|
- lib/html2rss/html/article_extractor/image_extractor.rb
|
|
342
|
+
- lib/html2rss/html/article_rules.rb
|
|
343
|
+
- lib/html2rss/html/article_rules/category.rb
|
|
344
|
+
- lib/html2rss/html/article_rules/date.rb
|
|
345
|
+
- lib/html2rss/html/article_rules/enclosure.rb
|
|
346
|
+
- lib/html2rss/html/article_rules/image.rb
|
|
347
347
|
- lib/html2rss/html/navigator.rb
|
|
348
348
|
- lib/html2rss/html/navigator/text_extractor.rb
|
|
349
349
|
- lib/html2rss/html/rendering.rb
|
|
350
350
|
- lib/html2rss/html/rendering/audio_renderer.rb
|
|
351
351
|
- lib/html2rss/html/rendering/description_builder.rb
|
|
352
|
+
- lib/html2rss/html/rendering/escaped_attributes.rb
|
|
352
353
|
- lib/html2rss/html/rendering/image_renderer.rb
|
|
353
354
|
- lib/html2rss/html/rendering/media_renderer.rb
|
|
354
355
|
- lib/html2rss/html/rendering/pdf_renderer.rb
|
|
355
356
|
- lib/html2rss/html/rendering/video_renderer.rb
|
|
357
|
+
- lib/html2rss/html/sst_article_extractor.rb
|
|
358
|
+
- lib/html2rss/link_destination.rb
|
|
359
|
+
- lib/html2rss/link_destination/destination_facts.rb
|
|
360
|
+
- lib/html2rss/link_destination/noise_policy.rb
|
|
361
|
+
- lib/html2rss/link_destination/path_classifier.rb
|
|
362
|
+
- lib/html2rss/link_destination/text_classifier.rb
|
|
356
363
|
- lib/html2rss/request_service.rb
|
|
357
364
|
- lib/html2rss/request_service/blocked_surface.rb
|
|
358
365
|
- lib/html2rss/request_service/botasaurus_contract.rb
|
|
@@ -378,6 +385,15 @@ files:
|
|
|
378
385
|
- lib/html2rss/request_session/pager/offset.rb
|
|
379
386
|
- lib/html2rss/request_session/pager/rel_next.rb
|
|
380
387
|
- lib/html2rss/request_session/pager/url_template.rb
|
|
388
|
+
- lib/html2rss/scoring.rb
|
|
389
|
+
- lib/html2rss/scoring/anchor_score.rb
|
|
390
|
+
- lib/html2rss/scoring/cluster_scorer.rb
|
|
391
|
+
- lib/html2rss/scoring/container_assessor.rb
|
|
392
|
+
- lib/html2rss/scoring/engine.rb
|
|
393
|
+
- lib/html2rss/scoring/link_resolver.rb
|
|
394
|
+
- lib/html2rss/scoring/observation.rb
|
|
395
|
+
- lib/html2rss/scoring/ranked_segment.rb
|
|
396
|
+
- lib/html2rss/scoring/score.rb
|
|
381
397
|
- lib/html2rss/selectors.rb
|
|
382
398
|
- lib/html2rss/selectors/extractors.rb
|
|
383
399
|
- lib/html2rss/selectors/extractors/attribute.rb
|
|
@@ -399,6 +415,14 @@ files:
|
|
|
399
415
|
- lib/html2rss/selectors/post_processors/sanitize_html.rb
|
|
400
416
|
- lib/html2rss/selectors/post_processors/substring.rb
|
|
401
417
|
- lib/html2rss/selectors/post_processors/template.rb
|
|
418
|
+
- lib/html2rss/sst.rb
|
|
419
|
+
- lib/html2rss/sst/attrs.rb
|
|
420
|
+
- lib/html2rss/sst/document.rb
|
|
421
|
+
- lib/html2rss/sst/index.rb
|
|
422
|
+
- lib/html2rss/sst/node.rb
|
|
423
|
+
- lib/html2rss/sst/normalizer.rb
|
|
424
|
+
- lib/html2rss/sst/tags.rb
|
|
425
|
+
- lib/html2rss/sst/text.rb
|
|
402
426
|
- lib/html2rss/status.rb
|
|
403
427
|
- lib/html2rss/url.rb
|
|
404
428
|
- lib/html2rss/version.rb
|
|
@@ -409,7 +433,7 @@ licenses:
|
|
|
409
433
|
- MIT
|
|
410
434
|
metadata:
|
|
411
435
|
allowed_push_host: https://rubygems.org
|
|
412
|
-
changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.
|
|
436
|
+
changelog_uri: https://github.com/html2rss/html2rss/releases/tag/v0.25.0
|
|
413
437
|
rubygems_mfa_required: 'true'
|
|
414
438
|
rdoc_options: []
|
|
415
439
|
require_paths:
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Html2rss
|
|
4
|
-
class AutoSource
|
|
5
|
-
module Discovery
|
|
6
|
-
class DomClustering
|
|
7
|
-
##
|
|
8
|
-
# Scores candidate DOM groups using heading, time, date, and word-count signals.
|
|
9
|
-
class GroupScorer
|
|
10
|
-
def initialize
|
|
11
|
-
@text_words = {}.compare_by_identity
|
|
12
|
-
@has_date = {}.compare_by_identity
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
# @param groups [Hash{String => Array<Nokogiri::XML::Node>}] candidate DOM groups
|
|
16
|
-
# @return [Array<Nokogiri::XML::Node>] nodes from the highest-scoring group
|
|
17
|
-
def select_best_group(groups)
|
|
18
|
-
best_nodes = []
|
|
19
|
-
best_score = -1
|
|
20
|
-
|
|
21
|
-
groups.each_value do |nodes|
|
|
22
|
-
score = score_group(nodes)
|
|
23
|
-
next if score.negative?
|
|
24
|
-
|
|
25
|
-
(best_nodes = nodes) && (best_score = score) if score > best_score
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
best_nodes
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# @param nodes [Array<Nokogiri::XML::Node>]
|
|
32
|
-
# @return [Float] average visible word count across nodes
|
|
33
|
-
def avg_words(nodes)
|
|
34
|
-
nodes.sum { |n| text_words(n) } / nodes.size.to_f
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
private
|
|
38
|
-
|
|
39
|
-
def score_group(nodes)
|
|
40
|
-
avg_w = avg_words(nodes)
|
|
41
|
-
return -1 if avg_w < 5
|
|
42
|
-
|
|
43
|
-
score = nodes.size + (avg_w / 5.0)
|
|
44
|
-
score += 20 if nodes_heading?(nodes)
|
|
45
|
-
score += 20 if nodes_time?(nodes)
|
|
46
|
-
score += 40 if nodes_date?(nodes)
|
|
47
|
-
score
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def nodes_heading?(nodes)
|
|
51
|
-
nodes.any? do |n|
|
|
52
|
-
n.at_css(Html2rss::Html::Navigator::HEADING_TAGS.join(',')) ||
|
|
53
|
-
n.at_css('.font-bold, .font-semibold')
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def nodes_time?(nodes)
|
|
58
|
-
nodes.any? { |n| n.at_css('time, [datetime]') }
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def nodes_date?(nodes)
|
|
62
|
-
nodes.any? { |n| date?(n) }
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def text_words(node)
|
|
66
|
-
@text_words[node] ||= Html2rss::Html::Navigator.extract_visible_text(node).to_s.scan(/\p{Alnum}+/).size
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def date?(node)
|
|
70
|
-
@has_date[node] ||= begin
|
|
71
|
-
text = Html2rss::Html::Navigator.extract_visible_text(node).to_s
|
|
72
|
-
text.match?(%r{\b\d{4}[-/]\d{2}[-/]\d{2}\b}) ||
|
|
73
|
-
text.match?(/\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/i)
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
end
|