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