html2rss 0.22.2 → 0.24.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 +11 -108
- data/lib/html2rss/{articles → article}/deduplicator.rb +3 -4
- data/lib/html2rss/{rss_builder → article}/enclosure.rb +6 -22
- data/lib/html2rss/article.rb +213 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/group_scorer.rb +80 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering/overlap_resolver.rb +86 -0
- data/lib/html2rss/auto_source/discovery/dom_clustering.rb +119 -0
- data/lib/html2rss/auto_source/discovery/list_candidates.rb +94 -0
- data/lib/html2rss/auto_source/discovery/semantic_anchor_candidates.rb +219 -0
- data/lib/html2rss/auto_source/discovery/semantic_containers.rb +71 -0
- data/lib/html2rss/auto_source/discovery/sitemap.rb +159 -0
- data/lib/html2rss/auto_source/discovery.rb +14 -0
- data/lib/html2rss/auto_source/link_heuristics/anchor_signals.rb +28 -0
- data/lib/html2rss/auto_source/link_heuristics/container_assessor.rb +106 -0
- data/lib/html2rss/auto_source/link_heuristics/container_signals.rb +80 -0
- data/lib/html2rss/auto_source/link_heuristics/destination_facts.rb +42 -0
- data/lib/html2rss/auto_source/link_heuristics/href_extractor.rb +38 -0
- data/lib/html2rss/auto_source/link_heuristics/path_classifier.rb +221 -0
- data/lib/html2rss/auto_source/link_heuristics/text_classifier.rb +66 -0
- data/lib/html2rss/auto_source/link_heuristics.rb +139 -0
- data/lib/html2rss/auto_source/scraper/html.rb +22 -33
- data/lib/html2rss/auto_source/scraper/json_state.rb +1 -1
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +163 -0
- data/lib/html2rss/auto_source/scraper/microformats2.rb +146 -0
- data/lib/html2rss/auto_source/scraper/schema/category_extractor.rb +29 -114
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +50 -11
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +35 -7
- data/lib/html2rss/auto_source/scraper/schema.rb +61 -18
- data/lib/html2rss/auto_source/scraper/semantic_html/{deduplicator.rb → entry_deduplicator.rb} +3 -3
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +23 -171
- data/lib/html2rss/auto_source/scraper/sitemap.rb +153 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope/date_archive_range.rb +110 -0
- data/lib/html2rss/auto_source/scraper/wordpress_api/page_scope.rb +2 -78
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +9 -0
- data/lib/html2rss/auto_source/scraper.rb +10 -3
- data/lib/html2rss/auto_source.rb +30 -35
- data/lib/html2rss/channel.rb +198 -0
- data/lib/html2rss/cli.rb +5 -6
- data/lib/html2rss/config/auto_source_contract.rb +51 -0
- data/lib/html2rss/config/request_controls.rb +136 -0
- data/lib/html2rss/config/request_headers.rb +1 -1
- data/lib/html2rss/config/schema.rb +65 -114
- data/lib/html2rss/{selectors/config.rb → config/selectors_validator.rb} +56 -7
- data/lib/html2rss/config/validator.rb +6 -6
- data/lib/html2rss/config.rb +176 -19
- data/lib/html2rss/{configuration.rb → defaults.rb} +12 -10
- data/lib/html2rss/feed_builder/item_presentation.rb +42 -0
- data/lib/html2rss/feed_builder/json_feed/item.rb +112 -0
- data/lib/html2rss/feed_builder/json_feed.rb +81 -0
- data/lib/html2rss/feed_builder/rss/stylesheet.rb +65 -0
- data/lib/html2rss/feed_builder/rss.rb +116 -0
- data/lib/html2rss/feed_builder.rb +15 -0
- data/lib/html2rss/feed_pipeline/auto_fallback.rb +120 -60
- data/lib/html2rss/feed_pipeline/runtime_policy.rb +108 -0
- data/lib/html2rss/feed_pipeline/strategy_plan.rb +64 -0
- data/lib/html2rss/feed_pipeline.rb +83 -89
- data/lib/html2rss/feed_result.rb +92 -0
- data/lib/html2rss/html/article_extractor/category_extractor.rb +123 -0
- data/lib/html2rss/html/article_extractor/date_extractor.rb +22 -0
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +95 -0
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +54 -0
- data/lib/html2rss/html/article_extractor/id_generator.rb +69 -0
- data/lib/html2rss/html/article_extractor/image_extractor.rb +60 -0
- data/lib/html2rss/html/article_extractor.rb +134 -0
- data/lib/html2rss/html/navigator/text_extractor.rb +79 -0
- data/lib/html2rss/html/navigator.rb +146 -0
- data/lib/html2rss/html/rendering/audio_renderer.rb +38 -0
- data/lib/html2rss/html/rendering/description_builder.rb +89 -0
- data/lib/html2rss/html/rendering/image_renderer.rb +43 -0
- data/lib/html2rss/html/rendering/media_renderer.rb +39 -0
- data/lib/html2rss/html/rendering/pdf_renderer.rb +36 -0
- data/lib/html2rss/html/rendering/video_renderer.rb +38 -0
- data/lib/html2rss/html/rendering.rb +25 -0
- data/lib/html2rss/html.rb +8 -0
- data/lib/html2rss/request_service/blocked_surface.rb +67 -0
- data/lib/html2rss/request_service/botasaurus_contract.rb +33 -9
- data/lib/html2rss/request_service/botasaurus_strategy.rb +21 -30
- data/lib/html2rss/request_service/browserless_strategy.rb +2 -23
- data/lib/html2rss/request_service/budget.rb +97 -18
- data/lib/html2rss/request_service/context.rb +1 -3
- data/lib/html2rss/request_service/faraday_strategy.rb +12 -26
- data/lib/html2rss/request_service/local_file_strategy.rb +12 -4
- data/lib/html2rss/request_service/network_guard.rb +125 -0
- data/lib/html2rss/request_service/policy.rb +10 -82
- data/lib/html2rss/request_service/puppet_commander/navigation_guards.rb +148 -0
- data/lib/html2rss/request_service/puppet_commander/preload_runner.rb +86 -0
- data/lib/html2rss/request_service/puppet_commander.rb +22 -191
- data/lib/html2rss/request_service/response.rb +9 -1
- data/lib/html2rss/request_service/response_guard.rb +1 -1
- data/lib/html2rss/request_service/strategy.rb +123 -5
- data/lib/html2rss/request_service.rb +7 -2
- data/lib/html2rss/request_session/pager/base.rb +83 -0
- data/lib/html2rss/request_session/pager/custom_selector.rb +39 -0
- data/lib/html2rss/request_session/pager/json_cursor.rb +78 -0
- data/lib/html2rss/request_session/pager/offset.rb +48 -0
- data/lib/html2rss/request_session/pager/rel_next.rb +23 -0
- data/lib/html2rss/request_session/pager/url_template.rb +48 -0
- data/lib/html2rss/request_session/pager.rb +75 -0
- data/lib/html2rss/request_session.rb +29 -16
- data/lib/html2rss/selectors/item_scope.rb +30 -0
- data/lib/html2rss/selectors/post_processors/base.rb +1 -1
- data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +0 -1
- data/lib/html2rss/selectors/post_processors/sanitize_html.rb +0 -2
- data/lib/html2rss/selectors/post_processors/template.rb +7 -3
- data/lib/html2rss/selectors.rb +84 -64
- data/lib/html2rss/status.rb +132 -0
- data/lib/html2rss/version.rb +1 -3
- data/lib/html2rss.rb +34 -22
- data/schema/html2rss-config.schema.json +109 -9
- metadata +71 -43
- data/lib/html2rss/auto_source/scraper/html/class_clustering.rb +0 -196
- data/lib/html2rss/auto_source/scraper/link_heuristics.rb +0 -401
- data/lib/html2rss/auto_source/scraper/schema/list_item.rb +0 -28
- data/lib/html2rss/auto_source/scraper/semantic_html/anchor_selector.rb +0 -49
- data/lib/html2rss/blocked_surface.rb +0 -65
- data/lib/html2rss/category_extractor.rb +0 -116
- data/lib/html2rss/config/class_methods.rb +0 -183
- data/lib/html2rss/html_extractor/date_extractor.rb +0 -20
- data/lib/html2rss/html_extractor/enclosure_extractor.rb +0 -91
- data/lib/html2rss/html_extractor/heading_extractor.rb +0 -50
- data/lib/html2rss/html_extractor/id_generator.rb +0 -67
- data/lib/html2rss/html_extractor/image_extractor.rb +0 -58
- data/lib/html2rss/html_extractor/list_candidates.rb +0 -88
- data/lib/html2rss/html_extractor/semantic_anchor_candidates.rb +0 -260
- data/lib/html2rss/html_extractor/semantic_containers.rb +0 -69
- data/lib/html2rss/html_extractor/text_extractor.rb +0 -77
- data/lib/html2rss/html_extractor.rb +0 -190
- data/lib/html2rss/html_navigator.rb +0 -71
- data/lib/html2rss/json_feed_builder/item.rb +0 -94
- data/lib/html2rss/json_feed_builder.rb +0 -59
- data/lib/html2rss/rendering/audio_renderer.rb +0 -36
- data/lib/html2rss/rendering/description_builder.rb +0 -87
- data/lib/html2rss/rendering/image_renderer.rb +0 -41
- data/lib/html2rss/rendering/media_renderer.rb +0 -37
- data/lib/html2rss/rendering/pdf_renderer.rb +0 -34
- data/lib/html2rss/rendering/video_renderer.rb +0 -36
- data/lib/html2rss/rendering.rb +0 -23
- data/lib/html2rss/request_controls.rb +0 -133
- data/lib/html2rss/request_session/rel_next_pager.rb +0 -70
- data/lib/html2rss/request_session/runtime_input.rb +0 -71
- data/lib/html2rss/request_session/runtime_policy.rb +0 -84
- data/lib/html2rss/rss_builder/article.rb +0 -208
- data/lib/html2rss/rss_builder/channel.rb +0 -118
- data/lib/html2rss/rss_builder/stylesheet.rb +0 -63
- data/lib/html2rss/rss_builder.rb +0 -101
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
module Rendering
|
|
6
|
+
# Builds a sanitized article description from the base text, title, and optional media.
|
|
7
|
+
#
|
|
8
|
+
# Combines media elements (images, audio, video, PDFs) with sanitized text content
|
|
9
|
+
# to create rich RSS descriptions that reveal more scraped information.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# builder = DescriptionBuilder.new(
|
|
13
|
+
# base: "Article content",
|
|
14
|
+
# title: "Article Title",
|
|
15
|
+
# url: "https://example.com",
|
|
16
|
+
# enclosures: [enclosure_object],
|
|
17
|
+
# image: "https://example.com/image.jpg"
|
|
18
|
+
# )
|
|
19
|
+
# description = builder.call
|
|
20
|
+
class DescriptionBuilder
|
|
21
|
+
# Removes the specified pattern from the beginning of the text
|
|
22
|
+
# within a given range if the pattern occurs before the range's end.
|
|
23
|
+
#
|
|
24
|
+
# @param text [String]
|
|
25
|
+
# @param pattern [String]
|
|
26
|
+
# @param end_of_range [Integer] Optional, defaults to half the text length
|
|
27
|
+
# @return [String]
|
|
28
|
+
def self.remove_pattern_from_start(text, pattern, end_of_range: (text.size * 0.5).to_i)
|
|
29
|
+
return text unless text.is_a?(String) && pattern.is_a?(String) && !pattern.empty?
|
|
30
|
+
|
|
31
|
+
index = text.index(pattern)
|
|
32
|
+
return text if index.nil? || index > end_of_range
|
|
33
|
+
|
|
34
|
+
"#{text[0, index]}#{text[(index + pattern.size)..]}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param base [String] The base text content for the description
|
|
38
|
+
# @param title [String] The article title (used for alt text and title removal)
|
|
39
|
+
# @param url [String, Html2rss::Url] The article URL (used for sanitization)
|
|
40
|
+
# @param enclosures [Array<Html2rss::Article::Enclosure>, nil] Media enclosures
|
|
41
|
+
# @param image [String, Html2rss::Url, nil] Fallback image URL
|
|
42
|
+
def initialize(base:, title:, url:, enclosures:, image:)
|
|
43
|
+
@base = base.to_s
|
|
44
|
+
@title = title
|
|
45
|
+
@url = url
|
|
46
|
+
@enclosures = Array(enclosures)
|
|
47
|
+
@image = image
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Generates the complete description with media and sanitized text.
|
|
51
|
+
#
|
|
52
|
+
# @return [String, nil] The complete description or nil if empty
|
|
53
|
+
def call
|
|
54
|
+
fragments = []
|
|
55
|
+
fragments.concat(Array(rendered_media))
|
|
56
|
+
fragments << processed_base_description
|
|
57
|
+
|
|
58
|
+
result = fragments.compact.join("\n\n").strip
|
|
59
|
+
result.empty? ? nil : result
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def rendered_media
|
|
65
|
+
rendered = render_enclosures
|
|
66
|
+
return rendered if rendered.any?
|
|
67
|
+
return render_fallback_image if @image
|
|
68
|
+
|
|
69
|
+
[]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def render_enclosures
|
|
73
|
+
@enclosures.filter_map do |enclosure|
|
|
74
|
+
MediaRenderer.for(enclosure:, image: @image, title: @title)&.to_html
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def render_fallback_image
|
|
79
|
+
[MediaRenderer.for(enclosure: nil, image: @image, title: @title)&.to_html]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def processed_base_description
|
|
83
|
+
text = self.class.remove_pattern_from_start(@base, @title)
|
|
84
|
+
Html2rss::Selectors::PostProcessors::SanitizeHtml.get(text, @url)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
module Html
|
|
7
|
+
module Rendering
|
|
8
|
+
# Renders an HTML <img> tag from a URL and title.
|
|
9
|
+
class ImageRenderer
|
|
10
|
+
# @param url [String, Html2rss::Url] image URL for the src attribute
|
|
11
|
+
# @param title [String, nil] title/alt text for the image
|
|
12
|
+
def initialize(url:, title:)
|
|
13
|
+
@url = url
|
|
14
|
+
@title = title
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [String] HTML image snippet for article rendering
|
|
18
|
+
def to_html
|
|
19
|
+
attributes = [
|
|
20
|
+
%(src="#{escaped_url}"),
|
|
21
|
+
%(alt="#{escaped_title}"),
|
|
22
|
+
%(title="#{escaped_title}"),
|
|
23
|
+
'loading="lazy"',
|
|
24
|
+
'referrerpolicy="no-referrer"',
|
|
25
|
+
'decoding="async"',
|
|
26
|
+
'crossorigin="anonymous"'
|
|
27
|
+
]
|
|
28
|
+
"<img #{attributes.join(' ')}>"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def escaped_url
|
|
34
|
+
CGI.escapeHTML(@url.to_s)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def escaped_title
|
|
38
|
+
CGI.escapeHTML(@title.to_s)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
module Rendering
|
|
6
|
+
# Factory: picks the appropriate renderer for a given enclosure or fallback image.
|
|
7
|
+
class MediaRenderer
|
|
8
|
+
# @param enclosure [Html2rss::Article::Enclosure, nil]
|
|
9
|
+
# @param image [String, Html2rss::Url, nil] Fallback image URL
|
|
10
|
+
# @param title [String]
|
|
11
|
+
# @return [ImageRenderer, VideoRenderer, AudioRenderer, PdfRenderer, nil]
|
|
12
|
+
def self.for(enclosure:, image:, title:)
|
|
13
|
+
return ImageRenderer.new(url: image, title:) if enclosure.nil? && image
|
|
14
|
+
return nil unless enclosure
|
|
15
|
+
|
|
16
|
+
create_renderer_for_type(enclosure.type, url: enclosure.url, title:)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @private
|
|
20
|
+
# @param type [String, nil] enclosure MIME type
|
|
21
|
+
# @param url [String, Html2rss::Url] enclosure URL
|
|
22
|
+
# @param title [String, nil] title used by image renderer
|
|
23
|
+
# @return [ImageRenderer, VideoRenderer, AudioRenderer, PdfRenderer, nil]
|
|
24
|
+
def self.create_renderer_for_type(type, url:, title:)
|
|
25
|
+
case type
|
|
26
|
+
when %r{^image/}
|
|
27
|
+
ImageRenderer.new(url:, title:)
|
|
28
|
+
when %r{^video/}
|
|
29
|
+
VideoRenderer.new(url:, type:)
|
|
30
|
+
when %r{^audio/}
|
|
31
|
+
AudioRenderer.new(url:, type:)
|
|
32
|
+
when 'application/pdf'
|
|
33
|
+
PdfRenderer.new(url:)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
module Html
|
|
7
|
+
module Rendering
|
|
8
|
+
# Renders an HTML <iframe> for PDF documents.
|
|
9
|
+
class PdfRenderer
|
|
10
|
+
# @param url [String, Html2rss::Url] PDF URL rendered in the iframe
|
|
11
|
+
def initialize(url:)
|
|
12
|
+
@url = url
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @return [String] HTML iframe snippet for PDF rendering
|
|
16
|
+
def to_html
|
|
17
|
+
attributes = [
|
|
18
|
+
%(src="#{escaped_url}"),
|
|
19
|
+
'width="100%"',
|
|
20
|
+
'height="75vh"',
|
|
21
|
+
'sandbox=""',
|
|
22
|
+
'referrerpolicy="no-referrer"',
|
|
23
|
+
'loading="lazy"'
|
|
24
|
+
]
|
|
25
|
+
"<iframe #{attributes.join(' ')}></iframe>"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def escaped_url
|
|
31
|
+
CGI.escapeHTML(@url.to_s)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
5
|
+
module Html2rss
|
|
6
|
+
module Html
|
|
7
|
+
module Rendering
|
|
8
|
+
# Renders an HTML <video> tag from a URL and type.
|
|
9
|
+
class VideoRenderer
|
|
10
|
+
# @param url [String, Html2rss::Url] media URL for the video source
|
|
11
|
+
# @param type [String] MIME type for the video source
|
|
12
|
+
def initialize(url:, type:)
|
|
13
|
+
@url = url
|
|
14
|
+
@type = type
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [String] HTML video snippet for article rendering
|
|
18
|
+
def to_html
|
|
19
|
+
[
|
|
20
|
+
'<video controls preload="none" referrerpolicy="no-referrer" crossorigin="anonymous" playsinline>',
|
|
21
|
+
%(<source src="#{escaped_url}" type="#{escaped_type}">),
|
|
22
|
+
'</video>'
|
|
23
|
+
].join
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def escaped_url
|
|
29
|
+
CGI.escapeHTML(@url.to_s)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def escaped_type
|
|
33
|
+
CGI.escapeHTML(@type.to_s)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module Html
|
|
5
|
+
# Namespace for HTML rendering logic, used to generate rich content such as
|
|
6
|
+
# images, audio, video, or embedded documents for feed descriptions.
|
|
7
|
+
#
|
|
8
|
+
# @see Html2rss::Html::Rendering::DescriptionBuilder
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# Html2rss::Html::Rendering::ImageRenderer.new(
|
|
12
|
+
# url: "https://example.com/image.jpg",
|
|
13
|
+
# title: "Example"
|
|
14
|
+
# ).to_html
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# Html2rss::Html::Rendering::MediaRenderer.for(
|
|
18
|
+
# enclosure: nil,
|
|
19
|
+
# image: "https://example.com/image.jpg",
|
|
20
|
+
# title: "Example"
|
|
21
|
+
# )
|
|
22
|
+
module Rendering
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class RequestService
|
|
5
|
+
##
|
|
6
|
+
# Shared anti-bot/interstitial signatures used by request and auto-source flows.
|
|
7
|
+
#
|
|
8
|
+
# This module centralizes signature matching so request-time guards and
|
|
9
|
+
# auto-source surface classification stay consistent.
|
|
10
|
+
module BlockedSurface
|
|
11
|
+
# Known interstitial fingerprints used to detect blocked or anti-bot surfaces.
|
|
12
|
+
INTERSTITIAL_SIGNATURES = [
|
|
13
|
+
{
|
|
14
|
+
key: :cloudflare_interstitial,
|
|
15
|
+
min_matches: 2,
|
|
16
|
+
patterns: [
|
|
17
|
+
%r{<title>\s*just a moment\.\.\.\s*</title>}i,
|
|
18
|
+
/checking your browser before accessing/i,
|
|
19
|
+
/please (?:enable|turn on) javascript and cookies/i,
|
|
20
|
+
%r{cdn-cgi/challenge-platform}i,
|
|
21
|
+
/cloudflare ray id/i
|
|
22
|
+
],
|
|
23
|
+
message: 'Blocked surface detected: Cloudflare anti-bot interstitial page. ' \
|
|
24
|
+
'Retry with --strategy browserless, try a more specific public listing URL, ' \
|
|
25
|
+
'or run from an environment that can complete anti-bot checks.'
|
|
26
|
+
}
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
##
|
|
30
|
+
# Returns the first matching interstitial signature for the provided body.
|
|
31
|
+
#
|
|
32
|
+
# @param body [String, nil] response body candidate
|
|
33
|
+
# @return [Hash, nil] signature hash when matched, otherwise nil
|
|
34
|
+
def self.interstitial_signature_for(body)
|
|
35
|
+
normalized_body = normalize_body(body)
|
|
36
|
+
INTERSTITIAL_SIGNATURES.find { |signature| interstitial_signature_match?(normalized_body, signature) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
##
|
|
40
|
+
# @param body [String, nil] response body candidate
|
|
41
|
+
# @return [Boolean] true when body matches a known interstitial signature
|
|
42
|
+
def self.interstitial?(body)
|
|
43
|
+
!interstitial_signature_for(body).nil?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.interstitial_signature_match?(body, signature)
|
|
47
|
+
min_matches = signature.fetch(:min_matches, 1)
|
|
48
|
+
matches = 0
|
|
49
|
+
|
|
50
|
+
signature.fetch(:patterns).each do |pattern|
|
|
51
|
+
matches += 1 if pattern.match?(body)
|
|
52
|
+
return true if matches >= min_matches
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
private_class_method :interstitial_signature_match?
|
|
58
|
+
|
|
59
|
+
def self.normalize_body(body)
|
|
60
|
+
body.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '')
|
|
61
|
+
rescue Encoding::CompatibilityError, Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
|
|
62
|
+
body.to_s.force_encoding(Encoding::UTF_8).scrub
|
|
63
|
+
end
|
|
64
|
+
private_class_method :normalize_body
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -2,11 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
|
|
5
|
-
##
|
|
6
|
-
# Main html2rss namespace.
|
|
7
5
|
module Html2rss
|
|
8
|
-
##
|
|
9
|
-
# Request transport orchestration and strategies.
|
|
10
6
|
class RequestService
|
|
11
7
|
##
|
|
12
8
|
# Maps html2rss request/response handling to the botasaurus-scrape-api contract.
|
|
@@ -14,7 +10,7 @@ module Html2rss
|
|
|
14
10
|
# Default Botasaurus scrape options when no explicit config is provided.
|
|
15
11
|
DEFAULT_OPTIONS = {
|
|
16
12
|
navigation_mode: 'auto',
|
|
17
|
-
max_retries:
|
|
13
|
+
max_retries: 1,
|
|
18
14
|
headless: false
|
|
19
15
|
}.freeze
|
|
20
16
|
|
|
@@ -34,6 +30,17 @@ module Html2rss
|
|
|
34
30
|
lang
|
|
35
31
|
].freeze
|
|
36
32
|
|
|
33
|
+
# Allowlisted upstream response keys exposed as Response#transport_meta.
|
|
34
|
+
META_KEYS = %w[
|
|
35
|
+
request_id strategy_used render_ms challenge_detected blocked_detected attempts error_category
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# Remaining seconds at or below which Botasaurus retries are disabled.
|
|
39
|
+
TIGHT_BUDGET_SECONDS = 12
|
|
40
|
+
|
|
41
|
+
# Seconds reserved from remaining budget before setting wait_timeout_seconds.
|
|
42
|
+
BUDGET_WAIT_RESERVE_SECONDS = 2
|
|
43
|
+
|
|
37
44
|
# Parsed Botasaurus response wrapper.
|
|
38
45
|
class ParsedResponse
|
|
39
46
|
# Fallback headers when upstream omits response headers.
|
|
@@ -94,6 +101,9 @@ module Html2rss
|
|
|
94
101
|
# @return [String, nil] final URL reported by upstream
|
|
95
102
|
def final_url = payload['final_url']
|
|
96
103
|
|
|
104
|
+
# @return [Hash{String => Object}] allowlisted upstream telemetry (frozen)
|
|
105
|
+
def transport_meta = payload.slice(*META_KEYS).compact.freeze
|
|
106
|
+
|
|
97
107
|
private
|
|
98
108
|
|
|
99
109
|
attr_reader :payload, :transport_status
|
|
@@ -113,6 +123,7 @@ module Html2rss
|
|
|
113
123
|
##
|
|
114
124
|
# @param url [Html2rss::Url] canonical URL to scrape
|
|
115
125
|
# @param options [Hash] validated request.botasaurus options
|
|
126
|
+
# @param remaining_timeout_seconds [Numeric, nil] shared request budget remainder for clamps
|
|
116
127
|
# @option options [String] :navigation_mode
|
|
117
128
|
# @option options [Integer] :max_retries
|
|
118
129
|
# @option options [String] :wait_for_selector
|
|
@@ -125,14 +136,15 @@ module Html2rss
|
|
|
125
136
|
# @option options [String] :user_agent
|
|
126
137
|
# @option options [Array<Integer>] :window_size
|
|
127
138
|
# @option options [String] :lang
|
|
128
|
-
def initialize(url:, options: {})
|
|
139
|
+
def initialize(url:, options: {}, remaining_timeout_seconds: nil)
|
|
129
140
|
@url = url
|
|
130
141
|
@options = options
|
|
142
|
+
@remaining_timeout_seconds = remaining_timeout_seconds
|
|
131
143
|
end
|
|
132
144
|
|
|
133
|
-
# @return [Hash] payload for POST /scrape
|
|
145
|
+
# @return [Hash] payload for POST /scrape (budget-clamped when remaining is known)
|
|
134
146
|
def request_payload
|
|
135
|
-
DEFAULT_OPTIONS.merge(filtered_options).merge(url: url.to_s)
|
|
147
|
+
DEFAULT_OPTIONS.merge(filtered_options).merge(url: url.to_s).then { clamp_for_budget(_1) }
|
|
136
148
|
end
|
|
137
149
|
|
|
138
150
|
# @param transport_response [Faraday::Response] upstream HTTP response
|
|
@@ -149,13 +161,25 @@ module Html2rss
|
|
|
149
161
|
|
|
150
162
|
private
|
|
151
163
|
|
|
152
|
-
attr_reader :url, :options
|
|
164
|
+
attr_reader :url, :options, :remaining_timeout_seconds
|
|
153
165
|
|
|
154
166
|
def filtered_options
|
|
155
167
|
OPTION_KEYS.each_with_object({}) do |key, normalized|
|
|
156
168
|
normalized[key] = options[key] if options.key?(key)
|
|
157
169
|
end
|
|
158
170
|
end
|
|
171
|
+
|
|
172
|
+
def clamp_for_budget(payload)
|
|
173
|
+
remaining = remaining_timeout_seconds
|
|
174
|
+
return payload if remaining.nil?
|
|
175
|
+
|
|
176
|
+
clamped = payload.dup
|
|
177
|
+
clamped[:max_retries] = 0 if remaining <= TIGHT_BUDGET_SECONDS
|
|
178
|
+
budget_wait = [1, (remaining - BUDGET_WAIT_RESERVE_SECONDS).floor].max
|
|
179
|
+
configured = clamped[:wait_timeout_seconds]
|
|
180
|
+
clamped[:wait_timeout_seconds] = configured ? [configured, budget_wait].min : budget_wait
|
|
181
|
+
clamped
|
|
182
|
+
end
|
|
159
183
|
end
|
|
160
184
|
end
|
|
161
185
|
end
|
|
@@ -8,47 +8,27 @@ module Html2rss
|
|
|
8
8
|
##
|
|
9
9
|
# Strategy to delegate fetching to a Botasaurus scrape API.
|
|
10
10
|
class BotasaurusStrategy < Strategy
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
# @return [Response] normalized request response
|
|
15
|
-
# @raise [BotasaurusConfigurationError] when BOTASAURUS_SCRAPER_URL is missing or invalid
|
|
16
|
-
# @raise [BotasaurusConnectionFailed] when Botasaurus cannot be reached or returns an invalid payload
|
|
17
|
-
# @raise [RequestTimedOut] when the Botasaurus request exceeds configured timeout
|
|
18
|
-
def execute
|
|
19
|
-
check_timeout!
|
|
20
|
-
validate_request!
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def fetch
|
|
21
14
|
parsed_response = post_scrape_request
|
|
22
15
|
raise_if_challenge_blocked!(parsed_response)
|
|
23
16
|
raise_if_upstream_failed!(parsed_response)
|
|
24
17
|
build_response(parsed_response)
|
|
25
|
-
rescue Faraday::TimeoutError, Timeout::Error => error
|
|
26
|
-
raise RequestTimedOut, error.message
|
|
27
|
-
rescue Faraday::ConnectionFailed, Faraday::SSLError => error
|
|
28
|
-
raise BotasaurusConnectionFailed, "Botasaurus connection failed: #{error.message}"
|
|
29
18
|
end
|
|
30
19
|
|
|
31
|
-
private
|
|
32
|
-
|
|
33
20
|
def post_scrape_request
|
|
34
21
|
transport_response = client.post('/scrape', JSON.generate(contract.request_payload), content_type_header)
|
|
35
22
|
contract.parse_response(transport_response)
|
|
36
23
|
end
|
|
37
24
|
|
|
38
|
-
def validate_request!
|
|
39
|
-
ctx.budget.consume!
|
|
40
|
-
ctx.policy.validate_request!(url: ctx.url, origin_url: ctx.origin_url, relation: ctx.relation)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
25
|
def build_response(parsed_response)
|
|
44
|
-
body = parsed_response.html
|
|
45
|
-
ResponseGuard.new(policy: ctx.policy).inspect_body!(body)
|
|
46
|
-
|
|
47
26
|
Response.new(
|
|
48
|
-
body
|
|
27
|
+
body: parsed_response.html,
|
|
49
28
|
headers: parsed_response.headers,
|
|
50
29
|
url: response_url(parsed_response.final_url),
|
|
51
|
-
status: parsed_response.status
|
|
30
|
+
status: parsed_response.status,
|
|
31
|
+
transport_meta: parsed_response.transport_meta
|
|
52
32
|
)
|
|
53
33
|
end
|
|
54
34
|
|
|
@@ -73,7 +53,11 @@ module Html2rss
|
|
|
73
53
|
end
|
|
74
54
|
|
|
75
55
|
def contract
|
|
76
|
-
@contract ||= BotasaurusContract.new(
|
|
56
|
+
@contract ||= BotasaurusContract.new(
|
|
57
|
+
url: ctx.url,
|
|
58
|
+
options: ctx.request.fetch(:botasaurus, {}),
|
|
59
|
+
remaining_timeout_seconds: attempt_timeout_seconds
|
|
60
|
+
)
|
|
77
61
|
end
|
|
78
62
|
|
|
79
63
|
def client
|
|
@@ -81,10 +65,13 @@ module Html2rss
|
|
|
81
65
|
end
|
|
82
66
|
|
|
83
67
|
def request_options
|
|
84
|
-
timeout
|
|
85
|
-
|
|
68
|
+
{ timeout: attempt_timeout_seconds.to_i }
|
|
69
|
+
end
|
|
86
70
|
|
|
87
|
-
|
|
71
|
+
def attempt_timeout_seconds
|
|
72
|
+
@attempt_timeout_seconds ||= ctx.budget.effective_timeout_seconds(
|
|
73
|
+
fallback: ctx.policy.total_timeout_seconds
|
|
74
|
+
)
|
|
88
75
|
end
|
|
89
76
|
|
|
90
77
|
def content_type_header
|
|
@@ -101,6 +88,10 @@ module Html2rss
|
|
|
101
88
|
raise BotasaurusConfigurationError, "BOTASAURUS_SCRAPER_URL is invalid: #{error.message}"
|
|
102
89
|
end
|
|
103
90
|
end
|
|
91
|
+
|
|
92
|
+
def translate_connection_error(error)
|
|
93
|
+
raise BotasaurusConnectionFailed, "Botasaurus connection failed: #{error.message}"
|
|
94
|
+
end
|
|
104
95
|
end
|
|
105
96
|
end
|
|
106
97
|
end
|
|
@@ -31,19 +31,6 @@ module Html2rss
|
|
|
31
31
|
# are aligned with the default values.
|
|
32
32
|
# @see https://github.com/browserless/browserless/pkgs/container/chromium
|
|
33
33
|
class BrowserlessStrategy < Strategy
|
|
34
|
-
##
|
|
35
|
-
# Executes a Browserless-backed request with the shared request policy.
|
|
36
|
-
#
|
|
37
|
-
# @return [Response] normalized request response
|
|
38
|
-
# @raise [RequestTimedOut] if the browser session exceeds the configured timeout
|
|
39
|
-
def execute
|
|
40
|
-
check_timeout!
|
|
41
|
-
validate_request!
|
|
42
|
-
execute_browserless_request
|
|
43
|
-
rescue Puppeteer::TimeoutError => error
|
|
44
|
-
raise RequestTimedOut, error.message
|
|
45
|
-
end
|
|
46
|
-
|
|
47
34
|
##
|
|
48
35
|
# @return [String] the Browserless websocket endpoint with token query param
|
|
49
36
|
# @raise [ArgumentError] if a custom endpoint is configured without an API token
|
|
@@ -58,12 +45,7 @@ module Html2rss
|
|
|
58
45
|
|
|
59
46
|
private
|
|
60
47
|
|
|
61
|
-
def
|
|
62
|
-
ctx.budget.consume!
|
|
63
|
-
ctx.policy.validate_request!(url: ctx.url, origin_url: ctx.origin_url, relation: ctx.relation)
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def execute_browserless_request
|
|
48
|
+
def fetch
|
|
67
49
|
connect_with_timeout_support do |browser|
|
|
68
50
|
PuppetCommander.new(ctx, browser).call
|
|
69
51
|
ensure
|
|
@@ -72,10 +54,7 @@ module Html2rss
|
|
|
72
54
|
end
|
|
73
55
|
|
|
74
56
|
def protocol_timeout_ms
|
|
75
|
-
|
|
76
|
-
raise RequestTimedOut, 'Request timed out' if timeout <= 0
|
|
77
|
-
|
|
78
|
-
(timeout * 1000).to_i
|
|
57
|
+
ctx.budget.effective_timeout_ms(fallback: ctx.policy.total_timeout_seconds)
|
|
79
58
|
end
|
|
80
59
|
|
|
81
60
|
def connect_with_timeout_support(&)
|