html2rss 0.33.0 → 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 -21
- 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 +126 -50
- 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 -39
- data/lib/html2rss/auto_source/scraper/json_document.rb +1 -3
- data/lib/html2rss/auto_source/scraper/json_state/article_normalizer.rb +119 -42
- data/lib/html2rss/auto_source/scraper/json_state/candidate_detector.rb +5 -16
- data/lib/html2rss/auto_source/scraper/json_state/value_finder.rb +2 -10
- data/lib/html2rss/auto_source/scraper/json_state.rb +2 -3
- data/lib/html2rss/auto_source/scraper/meta_oembed.rb +6 -14
- data/lib/html2rss/auto_source/scraper/microdata.rb +6 -4
- data/lib/html2rss/auto_source/scraper/microformats2.rb +1 -3
- data/lib/html2rss/auto_source/scraper/native_feed.rb +27 -17
- data/lib/html2rss/auto_source/scraper/schema/item_list.rb +5 -3
- data/lib/html2rss/auto_source/scraper/schema/thing.rb +68 -27
- data/lib/html2rss/auto_source/scraper/schema.rb +5 -8
- data/lib/html2rss/auto_source/scraper/semantic_html/entry_deduplicator.rb +3 -9
- data/lib/html2rss/auto_source/scraper/semantic_html.rb +1 -5
- data/lib/html2rss/auto_source/scraper/sitemap.rb +4 -3
- data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -3
- data/lib/html2rss/auto_source/scraper/xhr_articles.rb +1 -3
- data/lib/html2rss/auto_source/scraper.rb +10 -2
- data/lib/html2rss/auto_source/segment.rb +19 -5
- data/lib/html2rss/auto_source/segmenter/cluster.rb +13 -11
- data/lib/html2rss/auto_source/segmenter/list.rb +13 -5
- data/lib/html2rss/auto_source/segmenter/primary_link.rb +29 -4
- data/lib/html2rss/auto_source/segmenter/semantic.rb +17 -6
- data/lib/html2rss/auto_source/segmenter.rb +4 -8
- data/lib/html2rss/auto_source.rb +81 -26
- data/lib/html2rss/batch.rb +2 -0
- data/lib/html2rss/capture/README.md +5 -2
- data/lib/html2rss/capture.rb +44 -32
- data/lib/html2rss/channel.rb +30 -12
- data/lib/html2rss/cli.rb +22 -11
- 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 +6 -3
- data/lib/html2rss/config/schema.rb +15 -5
- data/lib/html2rss/config/selectors_validator.rb +2 -2
- data/lib/html2rss/config/validation_issue.rb +4 -1
- data/lib/html2rss/config/validator.rb +11 -0
- data/lib/html2rss/config.rb +53 -27
- data/lib/html2rss/contract_error.rb +9 -0
- data/lib/html2rss/defaults.rb +21 -23
- data/lib/html2rss/doctor/botasaurus.rb +7 -3
- data/lib/html2rss/error.rb +10 -0
- 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 +49 -6
- 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 +13 -31
- 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 +3 -4
- data/lib/html2rss/feed_resolution/policy.rb +1 -1
- data/lib/html2rss/feed_resolution/probe.rb +3 -1
- data/lib/html2rss/feed_resolution.rb +11 -12
- data/lib/html2rss/feed_result.rb +9 -5
- data/lib/html2rss/hash_util.rb +3 -3
- data/lib/html2rss/html/article_extractor/category_extractor.rb +1 -1
- data/lib/html2rss/html/article_extractor/enclosure_extractor.rb +12 -31
- data/lib/html2rss/html/article_extractor/heading_extractor.rb +2 -3
- data/lib/html2rss/html/article_extractor/id_generator.rb +6 -9
- 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 +4 -0
- 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 +100 -9
- 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 +5 -2
- data/lib/html2rss/mcp/contract.rb +13 -4
- data/lib/html2rss/mcp/outcome.rb +14 -12
- data/lib/html2rss/mcp/runtime.rb +39 -2
- data/lib/html2rss/mcp/server.rb +13 -4
- data/lib/html2rss/page_recon/README.md +1 -1
- data/lib/html2rss/page_recon/diagnostics.rb +2 -0
- data/lib/html2rss/recon.rb +10 -12
- data/lib/html2rss/request_service/blocked_surface.rb +2 -1
- data/lib/html2rss/request_service/botasaurus_contract.rb +25 -3
- data/lib/html2rss/request_service/botasaurus_strategy.rb +42 -10
- data/lib/html2rss/request_service/context.rb +15 -14
- 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 -2
- data/lib/html2rss/request_service/policy.rb +48 -11
- data/lib/html2rss/request_service/replay_breaker.rb +56 -0
- data/lib/html2rss/request_service/response.rb +30 -13
- 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 +0 -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 +6 -6
- 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/option_spec.rb +4 -1
- data/lib/html2rss/selectors/post_processors/base.rb +0 -8
- data/lib/html2rss/selectors/post_processors/gsub.rb +7 -7
- 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 +3 -3
- 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.rb +53 -12
- 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 +33 -26
- data/lib/html2rss/sst/normalizer.rb +31 -19
- data/lib/html2rss/sst/tags.rb +113 -2
- data/lib/html2rss/sst/text.rb +69 -29
- data/lib/html2rss/sst.rb +2 -0
- data/lib/html2rss/status.rb +6 -3
- data/lib/html2rss/surface_category.rb +1 -1
- data/lib/html2rss/syndication/README.md +12 -6
- data/lib/html2rss/syndication/alternate_links.rb +1 -1
- data/lib/html2rss/syndication/channel_facts.rb +87 -0
- data/lib/html2rss/syndication/discovery.rb +82 -28
- data/lib/html2rss/syndication/feed.rb +20 -0
- data/lib/html2rss/syndication/item_media.rb +103 -0
- data/lib/html2rss/syndication/parser.rb +75 -51
- data/lib/html2rss/test/enhance_audit.rb +2 -2
- data/lib/html2rss/test/item_sample.rb +43 -0
- data/lib/html2rss/test.rb +42 -14
- data/lib/html2rss/url.rb +115 -14
- data/lib/html2rss/version.rb +1 -1
- data/lib/html2rss/word_count.rb +53 -0
- data/lib/html2rss.rb +17 -8
- data/schema/html2rss-config.schema.json +56 -41
- metadata +31 -4
- data/lib/html2rss/html/rendering/media_renderer.rb +0 -39
- data/lib/html2rss/html/rendering/pdf_renderer.rb +0 -30
|
@@ -33,6 +33,7 @@ module Html2rss
|
|
|
33
33
|
(@link_resolver.utility_text?(text) && destination_facts.vanity_path) ||
|
|
34
34
|
utility_text_chrome?(text, destination_facts, heading_anchor:) ||
|
|
35
35
|
icon_only_anchor?(anchor, text) ||
|
|
36
|
+
concealed_anchor?(anchor) ||
|
|
36
37
|
utility_landmark_ancestor
|
|
37
38
|
end
|
|
38
39
|
|
|
@@ -42,16 +43,7 @@ module Html2rss
|
|
|
42
43
|
destination_facts.utility_path &&
|
|
43
44
|
!destination_facts.content_path &&
|
|
44
45
|
!destination_facts.strong_post_suffix &&
|
|
45
|
-
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def word_count_at_most?(text, max)
|
|
49
|
-
count = 0
|
|
50
|
-
text.to_s.scan(/\p{Alnum}+/) do
|
|
51
|
-
count += 1
|
|
52
|
-
return false if count > max
|
|
53
|
-
end
|
|
54
|
-
true
|
|
46
|
+
!WordCount.at_least?(text, 4)
|
|
55
47
|
end
|
|
56
48
|
|
|
57
49
|
def recommended_chrome?(text, destination_facts, heading_anchor:)
|
|
@@ -65,6 +57,15 @@ module Html2rss
|
|
|
65
57
|
!heading_anchor && !destination_facts.strong_post_suffix
|
|
66
58
|
end
|
|
67
59
|
|
|
60
|
+
# An anchor a sighted reader does not see, such as the off-screen accessibility twin of a teaser
|
|
61
|
+
# link ({Html::Visibility.concealed?}), is never the item.
|
|
62
|
+
def concealed_anchor?(anchor)
|
|
63
|
+
return false unless anchor
|
|
64
|
+
|
|
65
|
+
attrs = anchor.attrs
|
|
66
|
+
Html::Visibility.concealed?(attrs.class_names, style: attrs.style, hidden: attrs.raw.key?('hidden'))
|
|
67
|
+
end
|
|
68
|
+
|
|
68
69
|
def icon_only_anchor?(anchor, text)
|
|
69
70
|
return false unless anchor
|
|
70
71
|
return false if text.to_s.match?(/\p{Alnum}/)
|
|
@@ -15,13 +15,48 @@ module Html2rss
|
|
|
15
15
|
# Segment groups used to classify article, taxonomy, utility, and vanity routes.
|
|
16
16
|
# Utility = taxonomy ∪ chrome ∪ soft utility (do not re-list taxonomy tokens).
|
|
17
17
|
SEGMENT_SETS = begin
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
'
|
|
21
|
-
'
|
|
22
|
-
'
|
|
23
|
-
'
|
|
24
|
-
'
|
|
18
|
+
# Listing words: a path ending in one is a section index, not an article.
|
|
19
|
+
section = Set[
|
|
20
|
+
'articles', 'blog', 'blogs', 'changelogs', 'insights', 'launches', 'news', 'posts', 'releases',
|
|
21
|
+
'stories', 'updates', 'nachrichten', 'neuigkeiten', 'aktuelles', 'beitraege', 'articulos', 'noticias',
|
|
22
|
+
'entradas', 'publicaciones', 'actualites', 'nouvelles', 'teasers', 'cards',
|
|
23
|
+
'aktualnosci', 'aktualności', 'wiadomosci', 'wiadomości', 'artykuly', 'artykuły', 'wpisy', 'notizie',
|
|
24
|
+
'articoli', 'novita', 'novità', 'aggiornamenti', 'nieuws', 'nieuwsberichten', 'artikelen', 'berichten',
|
|
25
|
+
'nyheter', 'artiklar', 'ニュース', '記事', 'お知らせ', 'ブログ'
|
|
26
|
+
].freeze
|
|
27
|
+
content = (section | Set[
|
|
28
|
+
'article', 'changelog', 'insight', 'launch', 'post', 'release', 'story', 'update', 'artikel', 'beitrag',
|
|
29
|
+
'articulo', 'noticia', 'entrada', 'publicacion', 'actualite', 'nouvelle', 'teaser', 'card',
|
|
30
|
+
'artykul', 'artykuł', 'wpis', 'notizia', 'articolo', 'bericht', 'nyhet', 'inlagg', 'inlägg'
|
|
31
|
+
]).freeze
|
|
32
|
+
# People pages. `team` and `people` are left out on purpose: they name sports teams and
|
|
33
|
+
# newsmakers in article paths. The words below are unambiguous byline/staff words.
|
|
34
|
+
profile = Set[
|
|
35
|
+
'author', 'authors', 'staff', 'journalist', 'journalists', 'players',
|
|
36
|
+
'autor', 'autoren', 'redakteure', 'autores', 'periodista',
|
|
37
|
+
'periodistas', 'auteur', 'auteurs', 'journaliste', 'journalistes',
|
|
38
|
+
'autore', 'autori', 'giornalista', 'giornalisti', 'journalisten',
|
|
39
|
+
'autorzy', 'dziennikarz', 'dziennikarze', 'skribent', 'skribenter', 'forfattare',
|
|
40
|
+
'författare', '著者', '執筆者', '記者', 'ライター'
|
|
41
|
+
].freeze
|
|
42
|
+
# Editorial-office and contributor words: their child pages are often editorial notes with a
|
|
43
|
+
# three-word slug, so only a one- or two-word name slug marks a profile.
|
|
44
|
+
office = Set[
|
|
45
|
+
'contributor', 'contributors', 'redaktion', 'redaccion', 'redacción', 'redaction', 'rédaction',
|
|
46
|
+
'redazione', 'redactie', 'redakcja', '編集部'
|
|
47
|
+
].freeze
|
|
48
|
+
# Destinations that are never articles: games, horoscopes, quizzes, obituaries, livestreams, players.
|
|
49
|
+
non_article = Set[
|
|
50
|
+
'games', 'spiele', 'juegos', 'jeux', 'giochi', 'spellen', 'spel', 'gry', 'ゲーム',
|
|
51
|
+
'sudoku', 'crossword', 'crosswords', 'kreuzwortraetsel', 'kreuzworträtsel', 'crucigrama', 'crucigramas',
|
|
52
|
+
'quiz', 'quizzes', 'quizze', 'quizzen', 'kviz', 'クイズ',
|
|
53
|
+
'horoscope', 'horoscopes', 'horoskop', 'horoskope', 'horoscopo', 'horoscopos', 'oroscopo', 'oroscopi',
|
|
54
|
+
'horoscoop', 'horoskopy', '星座占い', '占い',
|
|
55
|
+
'obituary', 'obituaries', 'nachruf', 'nachrufe', 'traueranzeigen', 'todesanzeigen', 'necrologie',
|
|
56
|
+
'nécrologie', 'necrologio', 'necrologi', 'esquelas', 'obituarios', 'overlijdensberichten', 'nekrolog',
|
|
57
|
+
'nekrologi', 'dodsannonser', 'dödsannonser', '訃報', 'おくやみ',
|
|
58
|
+
'livestream', 'livestreams', 'endirect', 'envivo', 'indiretta', 'nazywo', 'ライブ配信',
|
|
59
|
+
'player'
|
|
25
60
|
].freeze
|
|
26
61
|
taxonomy = Set[
|
|
27
62
|
'category', 'categories', 'tag', 'tags', 'topic', 'topics',
|
|
@@ -52,15 +87,20 @@ module Html2rss
|
|
|
52
87
|
'career', 'careers', 'deals', 'deal', 'shopping', 'shop', 'trading', 'broker',
|
|
53
88
|
'versicherung', 'tierversicherung', 'insurance', 'vergleich', 'comparison',
|
|
54
89
|
'partnerboerse', 'singleboerse', 'krypto', 'crypto', 'casinos', 'casino',
|
|
55
|
-
'kreditkarten', 'kreditkarte', 'kredit', 'echtgeld', 'vpn', '
|
|
90
|
+
'kreditkarten', 'kreditkarte', 'kredit', 'echtgeld', 'vpn', 'kaufberater',
|
|
56
91
|
'leasing'
|
|
57
92
|
].freeze
|
|
58
93
|
utility = (taxonomy | chrome | SOFT_UTILITY).freeze
|
|
59
94
|
{
|
|
60
95
|
content:,
|
|
96
|
+
section:,
|
|
61
97
|
utility:,
|
|
62
98
|
high_confidence_junk: (utility - SOFT_UTILITY).freeze,
|
|
63
99
|
taxonomy:,
|
|
100
|
+
non_article:,
|
|
101
|
+
profile:,
|
|
102
|
+
office:,
|
|
103
|
+
people_index: (profile | office).freeze,
|
|
64
104
|
vanity:,
|
|
65
105
|
deep_post_context: Set['press', 'newsroom', 'presse', 'pressemitteilungen', 'prensa'].freeze
|
|
66
106
|
}.freeze
|
|
@@ -75,9 +115,23 @@ module Html2rss
|
|
|
75
115
|
# Common file-extension suffixes excluded from host-shaped junk matching.
|
|
76
116
|
FILE_EXTENSION_SEGMENT = /\.(?:pdf|jpe?g|png|gif|svg|webp|css|js|mjs|html?|xml|json|mp4|webm|zip|gz)\z/i
|
|
77
117
|
|
|
118
|
+
# A single person-name slug: letters only (dated and id slugs carry digits), up to three words.
|
|
119
|
+
NAME_SLUG = /\A\p{L}+(?:-\p{L}+){0,2}\z/
|
|
120
|
+
# Name slug of at most two words, for office/contributor sections whose notes carry longer slugs.
|
|
121
|
+
SHORT_NAME_SLUG = /\A\p{L}+(?:-\p{L}+)?\z/
|
|
122
|
+
# Name-slug shape that marks a profile page, by the word set of its parent segment.
|
|
123
|
+
NAME_SLUG_BY_OWNER = { profile: NAME_SLUG, office: SHORT_NAME_SLUG }.freeze
|
|
124
|
+
|
|
125
|
+
# Pop-up / embedded player segments such as `popup-audio-player` (hyphens removed before matching).
|
|
126
|
+
PLAYER_SEGMENT = /\A(?:(?:pop(?:up|out)|embed|audio|video|media)\w*)?player\z/
|
|
127
|
+
|
|
78
128
|
# @param segments [Array<String>] normalized URL path segments
|
|
79
|
-
|
|
129
|
+
# @param host_label [String, nil] first host label (e.g. an obituary subdomain)
|
|
130
|
+
# @param query [Boolean] whether the URL carries a query string (`/blog?p=12` selects one post)
|
|
131
|
+
def initialize(segments, host_label: nil, query: false)
|
|
80
132
|
@segments = segments
|
|
133
|
+
@query = query
|
|
134
|
+
@host_label = host_label.to_s.downcase
|
|
81
135
|
@lexicon = segments.map { ::Html2rss::Html::Probe.fold(_1) }.freeze
|
|
82
136
|
end
|
|
83
137
|
|
|
@@ -131,8 +185,17 @@ module Html2rss
|
|
|
131
185
|
all_junk?(segments.size - 1)
|
|
132
186
|
end
|
|
133
187
|
|
|
188
|
+
# @return [Boolean] true when the host or a path segment names a game, horoscope, quiz,
|
|
189
|
+
# obituary, livestream or player destination, or the path is a bare section index
|
|
190
|
+
def non_article_path?
|
|
191
|
+
return @non_article_path if defined?(@non_article_path)
|
|
192
|
+
|
|
193
|
+
@non_article_path = section_index? || profile_route? || (!strong_post_suffix? && non_article_word_present?)
|
|
194
|
+
end
|
|
195
|
+
|
|
134
196
|
# @return [Boolean] true when the route is shallow and contains high-confidence noise
|
|
135
197
|
def junk_path?
|
|
198
|
+
return true if non_article_path?
|
|
136
199
|
return false if excluded_content_route?
|
|
137
200
|
|
|
138
201
|
any_high_confidence_junk_segment? || shallow_vanity_route?
|
|
@@ -140,6 +203,7 @@ module Html2rss
|
|
|
140
203
|
|
|
141
204
|
# @return [Boolean] true when the route points at conversion or account chrome
|
|
142
205
|
def utility_destination?
|
|
206
|
+
return true if non_article_path?
|
|
143
207
|
return false if excluded_content_route?
|
|
144
208
|
|
|
145
209
|
vanity_path? || utility_route?
|
|
@@ -147,6 +211,33 @@ module Html2rss
|
|
|
147
211
|
|
|
148
212
|
private
|
|
149
213
|
|
|
214
|
+
def non_article_word_present?
|
|
215
|
+
([@host_label] + @lexicon).any? { non_article_word?(_1) }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def non_article_word?(word)
|
|
219
|
+
joined = word.delete('-_')
|
|
220
|
+
SEGMENT_SETS[:non_article].include?(joined) || joined.match?(PLAYER_SEGMENT)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# A people word that ends the path (an index) or is followed by one name slug (a profile page).
|
|
224
|
+
def profile_route?
|
|
225
|
+
return false if segments.empty?
|
|
226
|
+
|
|
227
|
+
SEGMENT_SETS[:people_index].include?(@lexicon.last) || profile_page?
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def profile_page?
|
|
231
|
+
return false if segments.size < 2
|
|
232
|
+
|
|
233
|
+
NAME_SLUG_BY_OWNER.any? { |set, slug| SEGMENT_SETS[set].include?(@lexicon[-2]) && segments.last.match?(slug) }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# A path ending in a section word (no slug) lists articles instead of being one.
|
|
237
|
+
def section_index?
|
|
238
|
+
!@query && segments.any? && SEGMENT_SETS[:section].include?(@lexicon.last)
|
|
239
|
+
end
|
|
240
|
+
|
|
150
241
|
def leading_high_confidence_junk?
|
|
151
242
|
segments.any? && high_confidence_junk_at?(0)
|
|
152
243
|
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module LinkDestination
|
|
5
|
+
module Sponsorship
|
|
6
|
+
##
|
|
7
|
+
# The closed multilingual vocabulary of sponsorship label text (en, de, fr, es, it, nl, pl, sv, ja), in
|
|
8
|
+
# two kinds. A paid phrase states paid content; an ad word names an ad and also something editorial
|
|
9
|
+
# (the advertising trade; "Promoted" a team or a person moving up). "Provided by" attribution ("提供")
|
|
10
|
+
# is in neither: it also credits wire and content partners. Only whole texts match; {Sponsorship}
|
|
11
|
+
# decides where a text is read.
|
|
12
|
+
module LabelText
|
|
13
|
+
# Letters with an accented variant are alternations, not classes: with +/i+, Ruby on Linux does not
|
|
14
|
+
# fold an uppercase accented letter against a class that also holds its ASCII base. Texts are read in
|
|
15
|
+
# NFC, so a decomposed accent matches too.
|
|
16
|
+
#
|
|
17
|
+
# A whole label, optionally wrapped in punctuation or separators ("Anzeige |", "[Sponsored]",
|
|
18
|
+
# "Publicité:", "【広告】"), in two groups. +paid+ states paid content; +ad+ is an ad word, which also
|
|
19
|
+
# names the advertising trade. Paid attribution ("Sponsored by …") may name the sponsor in up to five
|
|
20
|
+
# words.
|
|
21
|
+
LABEL_PATTERN = /
|
|
22
|
+
\A[\s\u00A0\p{P}|]*(?:(?<paid>
|
|
23
|
+
# English
|
|
24
|
+
sponsored(?:\s+(?:content|post|story))?|advertorial|(?:paid|partner)\s+content|paid\s+post|
|
|
25
|
+
sponsored\s+by(?:\s+\S+){0,5}|
|
|
26
|
+
# German
|
|
27
|
+
gesponsert(?:\s+von(?:\s+\S+){0,5})?|
|
|
28
|
+
# French
|
|
29
|
+
(?:contenu\s+)?sponsoris(?:e|é)(?:\s+par(?:\s+\S+){0,5})?|publireportage|contenus?\s+partenaires?|
|
|
30
|
+
# Spanish
|
|
31
|
+
(?:contenido\s+)?patrocinado(?:\s+por(?:\s+\S+){0,5})?|publirreportaje|
|
|
32
|
+
# Italian
|
|
33
|
+
(?:contenuto\s+)?sponsorizzat[oa](?:\s+(?:da|a\s+cura\s+di)(?:\s+\S+){0,5})?|
|
|
34
|
+
informazione\s+pubblicitaria|pubbliredazionale|
|
|
35
|
+
# Dutch
|
|
36
|
+
gesponsord(?:e\s+(?:inhoud|content))?(?:\s+door(?:\s+\S+){0,5})?|
|
|
37
|
+
# Polish
|
|
38
|
+
sponsorowane|materia(?:ł|l)\s+(?:sponsorowany|partnera|partnerski|promocyjny|reklamowy)|
|
|
39
|
+
materia(?:ł|l)y\s+(?:sponsorowane|partnera|partnerskie|promocyjne|reklamowe)|artyku(?:ł|l)\s+sponsorowany|
|
|
40
|
+
# Swedish
|
|
41
|
+
sponsrad|sponsrat(?:\s+inneh(?:å|a)ll)?|annonssamarbete|
|
|
42
|
+
# Japanese
|
|
43
|
+
タイアップ(?:広告|企画)?|スポンサー(?:リンク|コンテンツ)|広告企画|PR企画
|
|
44
|
+
)|(?<ad>
|
|
45
|
+
ad|advertisement|promoted|
|
|
46
|
+
anzeige|werbung|publicit(?:e|é)|publicidad|pubblicit(?:à|a)|advertentie|reclame|reklama|annons|
|
|
47
|
+
reklam|広告|スポンサー
|
|
48
|
+
))[\s\u00A0\p{P}|]*\z
|
|
49
|
+
/ix
|
|
50
|
+
# "PR" as a whole uppercase label element (a Japanese paid-content badge). Only element text counts:
|
|
51
|
+
# never an ARIA label, a heading or a topic chip, and never "Pr" or "pr".
|
|
52
|
+
ELEMENT_LABEL = /\A[\s\u00A0\p{P}|]*PR[\s\u00A0\p{P}|]*\z/
|
|
53
|
+
|
|
54
|
+
class << self
|
|
55
|
+
##
|
|
56
|
+
# @param text [String, nil] a whole text
|
|
57
|
+
# @param element [Boolean] whether +text+ is an element's text, the only place "PR" counts
|
|
58
|
+
# @return [Symbol, nil] +:paid_label+ for a paid phrase, +:label+ for an ad word, else nil
|
|
59
|
+
def kind(text, element: true)
|
|
60
|
+
return if text.nil?
|
|
61
|
+
|
|
62
|
+
text = text.unicode_normalize
|
|
63
|
+
return :label if element && text.match?(ELEMENT_LABEL)
|
|
64
|
+
|
|
65
|
+
match = LABEL_PATTERN.match(text)
|
|
66
|
+
match && (match[:paid] ? :paid_label : :label)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
##
|
|
70
|
+
# Cheap filter for the direct text of every node: whether it may hold a label at all.
|
|
71
|
+
#
|
|
72
|
+
# @param text [String]
|
|
73
|
+
# @return [Boolean]
|
|
74
|
+
def candidate?(text)
|
|
75
|
+
text = text.unicode_normalize
|
|
76
|
+
text.match?(LABEL_PATTERN) || text.include?('PR')
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
module LinkDestination
|
|
5
|
+
##
|
|
6
|
+
# Explicit sponsorship markers on SST nodes, in three kinds:
|
|
7
|
+
#
|
|
8
|
+
# - +:container+: a +rel+ containing +sponsored+, a truthy +data-sponsored+ or
|
|
9
|
+
# +data-advertorial+, a sponsored, advertorial or native-advertising class token (BEM variants
|
|
10
|
+
# included), or an ARIA label that is an ad label. The publisher states that the element is paid content.
|
|
11
|
+
# - +:label+: an ad-label class token (+ad-label+, +ad-badge__item+), or a standalone element whose whole
|
|
12
|
+
# text is an ad word ({LabelText} ad words: "Anzeige", "Publicité", "Promoted", "Reklama", "広告", or
|
|
13
|
+
# "PR" as an uppercase element text). An ad word also names something editorial (the advertising trade,
|
|
14
|
+
# a promotion), so it counts only on a plain element: never on a heading or a topic chip (category, tag
|
|
15
|
+
# or kicker class), and it never heads a section.
|
|
16
|
+
# - +:paid_label+: a standalone element whose whole text states paid content ({LabelText} paid
|
|
17
|
+
# phrases: "Sponsored", "Contenus partenaires", "Materiał partnera", "タイアップ"; paid attribution may
|
|
18
|
+
# name the sponsor in up to five words). It also counts on a heading or a topic chip, and
|
|
19
|
+
# {AutoSource::PageLinks} lets it head a section.
|
|
20
|
+
#
|
|
21
|
+
# Both text vocabularies are one closed multilingual set (en, de, fr, es, it, nl, pl, sv, ja).
|
|
22
|
+
# Attribution alone ("Presented by", "In Kooperation mit", "proposé par", "In collaborazione con", "提供")
|
|
23
|
+
# states no payment and is no label, nor is "Partners" (an editorial word).
|
|
24
|
+
#
|
|
25
|
+
# A class or text label counts only on a rendered element: neither it nor an ancestor inside its
|
|
26
|
+
# card ({CARD_NAMES}) is unrendered ({Html::Visibility.unrendered?}). +aria-hidden="true"+ and
|
|
27
|
+
# screen-reader-only classes still count: one is a visual label, the other a spoken one. A class
|
|
28
|
+
# marker also needs content (an empty template slot is not a label). A text label never counts
|
|
29
|
+
# inside running text: anchor text is never read, nor are words inside a heading or an inline element
|
|
30
|
+
# of text-bearing prose (or of an inline topic chip, for an ad word). A label inside an anchor counts
|
|
31
|
+
# (a linked label); a headline about advertising is not a marker.
|
|
32
|
+
#
|
|
33
|
+
# Markers only; {AutoSource::PageLinks} scopes a marker to the card that holds it.
|
|
34
|
+
module Sponsorship
|
|
35
|
+
# +rel+ token a publisher sets on paid links.
|
|
36
|
+
REL_TOKEN = 'sponsored'
|
|
37
|
+
# Attribute names that mark a sponsored element when set to a value in {ON_VALUES}.
|
|
38
|
+
ATTRIBUTE_NAMES = %w[data-sponsored data-advertorial].freeze
|
|
39
|
+
# Attribute values (case-insensitive) that switch a marker attribute on. Anything else is
|
|
40
|
+
# off: an empty value (template output or a bare attribute; HTML parsing cannot tell them
|
|
41
|
+
# apart), +no+, +null+, +false+, +0+, +off+.
|
|
42
|
+
ON_VALUES = Set['true', '1', 'yes'].freeze
|
|
43
|
+
# Sponsored class token: whole, behind a BEM block (+card--sponsored+, +teaser__advertorial+)
|
|
44
|
+
# or an +is-+ state prefix, with an optional content suffix (+sponsored-content+).
|
|
45
|
+
# +unsponsored+, +non-sponsored+ and +has-sponsored+ (a wrapper that contains one) do not match.
|
|
46
|
+
SPONSORED_CLASS = /
|
|
47
|
+
\A(?:[a-z0-9_-]*(?:--|__))?(?:is-)?(?:sponsored|advertorial)
|
|
48
|
+
(?:[-_](?:content|post|story|article|card|item|teaser))?\z
|
|
49
|
+
/x
|
|
50
|
+
# Native-advertising class token: a whole +native+, behind a BEM block (+teaser--native+,
|
|
51
|
+
# +card__native+), or a native-ad block (+native-ad+, +adv-native+, +ad-native+) with optional suffix
|
|
52
|
+
# words (+adv-native-strip+). +native-input+, +nativeApp+, +native-teaser+, +bt-langnative+ and a
|
|
53
|
+
# blockless +--native+ modifier do not match.
|
|
54
|
+
NATIVE_AD_CLASS = /
|
|
55
|
+
\A(?:[a-z0-9][a-z0-9_-]*?(?:--|__))?
|
|
56
|
+
(?:native|(?:native-ads?|adv-native|ad-native)(?:-[a-z0-9]+)*)\z
|
|
57
|
+
/x
|
|
58
|
+
# Ad-label class token family, with an optional BEM element or modifier: +ad-label+,
|
|
59
|
+
# +ad-labels__item+, +ad-badge+, +ad-marker--small+. Ad slots (+ad-container+) are not labels.
|
|
60
|
+
AD_LABEL_CLASS = /\Aad-(?:labels?|badge|marker)(?:(?:__|--)[a-z0-9-]+)?\z/
|
|
61
|
+
# ARIA attributes read as labels.
|
|
62
|
+
ARIA_NAMES = %w[aria-label aria-roledescription].freeze
|
|
63
|
+
# Elements that delimit one card (+main+ is a page region, never a card). A hidden ancestor hides
|
|
64
|
+
# a marker only inside its card: a hidden tab panel or responsive wrapper around whole cards
|
|
65
|
+
# hides none of their labels.
|
|
66
|
+
CARD_NAMES = Set[:article, :section, :aside, :li].freeze
|
|
67
|
+
# Class-token words (split on +-+/+_+) of topic chips, whose text names a topic, not a sale.
|
|
68
|
+
CHIP_WORDS = Set['category', 'categories', 'tag', 'tags', 'kicker', 'topic', 'topics', 'rubric', 'rubrik',
|
|
69
|
+
'eyebrow'].freeze
|
|
70
|
+
# Inline elements: a label inside one belongs to the surrounding text.
|
|
71
|
+
PHRASING_NAMES = Set[:span, :em, :strong, :b, :i, :q, :small, :mark, :u, :s, :cite, :abbr, :time, :sup,
|
|
72
|
+
:sub, :label, :font].freeze
|
|
73
|
+
class << self
|
|
74
|
+
##
|
|
75
|
+
# @param node [SST::Node]
|
|
76
|
+
# @param ancestors [Array<SST::Node>] +node+'s ancestors, root first
|
|
77
|
+
# @return [Symbol, nil] +:container+, +:label+ or +:paid_label+ when +node+ itself carries an
|
|
78
|
+
# explicit sponsorship marker, else nil
|
|
79
|
+
def marker(node, ancestors)
|
|
80
|
+
return :container if raw_marker?(node.attrs.raw)
|
|
81
|
+
|
|
82
|
+
kind = class_marker(node.attrs.class_names)
|
|
83
|
+
return (kind if visible?(node, ancestors)) if kind
|
|
84
|
+
|
|
85
|
+
text_label(node, ancestors)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
##
|
|
89
|
+
# Whether +text+, as a whole, states paid content ({LabelText}). For readers
|
|
90
|
+
# of plain text such as title pieces; an ad word is left out, as it also names a topic.
|
|
91
|
+
#
|
|
92
|
+
# @param text [String, nil]
|
|
93
|
+
# @return [Boolean]
|
|
94
|
+
def paid_text?(text) = LabelText.kind(text) == :paid_label
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Runs on every node of the page: literal checks gate each regex.
|
|
99
|
+
def class_marker(class_names)
|
|
100
|
+
kind = nil
|
|
101
|
+
class_names.each do |token|
|
|
102
|
+
return :container if sponsored_class?(token) || native_ad_class?(token)
|
|
103
|
+
|
|
104
|
+
kind ||= :label if token.start_with?('ad-') && token.match?(AD_LABEL_CLASS)
|
|
105
|
+
end
|
|
106
|
+
kind
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def sponsored_class?(token)
|
|
110
|
+
(token.include?('sponsored') || token.include?('advertorial')) && token.match?(SPONSORED_CLASS)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def native_ad_class?(token) = token.include?('native') && token.match?(NATIVE_AD_CLASS)
|
|
114
|
+
|
|
115
|
+
def visible?(node, ancestors)
|
|
116
|
+
(node.children.any? || node.own_text.match?(/\S/)) && !unrendered?(node, ancestors)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Whether +node+, or an ancestor up to and including its card, is hidden.
|
|
120
|
+
def unrendered?(node, ancestors)
|
|
121
|
+
return true if hidden?(node)
|
|
122
|
+
|
|
123
|
+
ancestors.reverse_each do |ancestor|
|
|
124
|
+
return true if hidden?(ancestor)
|
|
125
|
+
return false if CARD_NAMES.include?(ancestor.name)
|
|
126
|
+
end
|
|
127
|
+
false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def hidden?(node)
|
|
131
|
+
attrs = node.attrs
|
|
132
|
+
Html::Visibility.unrendered?(attrs.class_names, style: attrs.style, hidden: attrs.raw.key?('hidden'))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Most nodes carry no raw attributes, so the empty check skips every lookup.
|
|
136
|
+
def raw_marker?(raw)
|
|
137
|
+
return false if raw.empty?
|
|
138
|
+
|
|
139
|
+
sponsored_rel?(raw['rel']) ||
|
|
140
|
+
ATTRIBUTE_NAMES.any? { ON_VALUES.include?(raw[_1]&.strip&.downcase) } ||
|
|
141
|
+
ARIA_NAMES.any? { LabelText.kind(raw[_1], element: false) }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def sponsored_rel?(rel)
|
|
145
|
+
return false unless rel
|
|
146
|
+
|
|
147
|
+
rel.downcase.split.include?(REL_TOKEN)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The own-text check runs first: it is the cheap filter for every node of the page.
|
|
151
|
+
def text_label(node, ancestors)
|
|
152
|
+
return unless LabelText.candidate?(node.own_text)
|
|
153
|
+
|
|
154
|
+
kind = LabelText.kind(node.visible_text)
|
|
155
|
+
kind if kind && readable?(node, ancestors, paid: kind == :paid_label)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Anchor text is never read; an ad word is read only on a plain element.
|
|
159
|
+
def readable?(node, ancestors, paid:)
|
|
160
|
+
node.name != :a && (paid || plain?(node)) && ancestors.none?(&:heading?) &&
|
|
161
|
+
!in_running_text?(ancestors, paid:) && !unrendered?(node, ancestors)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def plain?(node) = !node.heading? && !chip?(node)
|
|
165
|
+
|
|
166
|
+
def chip?(node)
|
|
167
|
+
node.attrs.class_names.any? { |token| token.downcase.split(/[-_]+/).any? { CHIP_WORDS.include?(_1) } }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Climbs inline ancestors up to the first block one: prose in any of them, or (for an ad word) a
|
|
171
|
+
# chip class on an inline one, makes the label part of the surrounding text. Block classes are not
|
|
172
|
+
# read (post classes such as +category-news+ sit on whole cards).
|
|
173
|
+
def in_running_text?(ancestors, paid:)
|
|
174
|
+
ancestors.reverse_each do |ancestor|
|
|
175
|
+
return true if ancestor.own_text.match?(/\p{Alnum}/)
|
|
176
|
+
return false unless PHRASING_NAMES.include?(ancestor.name)
|
|
177
|
+
return true if !paid && chip?(ancestor)
|
|
178
|
+
end
|
|
179
|
+
false
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Html2rss
|
|
4
4
|
module LinkDestination
|
|
5
|
-
# Classifies visible anchor text for utility and
|
|
6
|
-
|
|
5
|
+
# Classifies visible anchor text for utility, recommendation and read-more chrome in en, de, fr, es, it,
|
|
6
|
+
# nl, pl, sv and ja. Letters with an accented variant are alternations, not classes: with +/i+, Ruby on
|
|
7
|
+
# Linux does not fold an uppercase accented letter against a class that also holds its ASCII base.
|
|
8
|
+
class TextClassifier # rubocop:disable Metrics/ClassLength -- nine-language vocabulary tables
|
|
7
9
|
# Prefix labels that usually identify navigation or subscription links.
|
|
8
10
|
UTILITY_PREFIX_PATTERN = /
|
|
9
11
|
\A\s*(
|
|
@@ -14,7 +16,17 @@ module Html2rss
|
|
|
14
16
|
# Spanish
|
|
15
17
|
ver\s+todos|ver\s+todo|todas\s+las\s+noticias|suscribirse|bolet(i|í)n|comentarios\s+feed|unirse|
|
|
16
18
|
# French
|
|
17
|
-
voir\s+tout|voir\s+tous|toutes\s+les\s+nouvelles|s['’]abonner|flux\s+de\s+commentaires|rejoindre
|
|
19
|
+
voir\s+tout|voir\s+tous|toutes\s+les\s+nouvelles|s['’]abonner|flux\s+de\s+commentaires|rejoindre|
|
|
20
|
+
# Italian
|
|
21
|
+
vedi\s+tutto|vedi\s+tutti|tutte\s+le\s+notizie|abbonati|newsletter|iscriviti|
|
|
22
|
+
# Dutch
|
|
23
|
+
bekijk\s+alles|alles\s+bekijken|al\s+het\s+nieuws|abonneren|nieuwsbrief|lid\s+worden|
|
|
24
|
+
# Polish
|
|
25
|
+
zobacz\s+wszystko|wszystkie\s+wiadomo(ś|s)ci|subskrybuj|newsletter|do(łą|la)cz|
|
|
26
|
+
# Swedish
|
|
27
|
+
visa\s+alla|se\s+alla|alla\s+nyheter|prenumerera|nyhetsbrev|bli\s+medlem|
|
|
28
|
+
# Japanese
|
|
29
|
+
すべて見る|全て見る|ニュースレター|メルマガ|会員登録|購読
|
|
18
30
|
)\b
|
|
19
31
|
/ix
|
|
20
32
|
# Short labels that usually identify non-article navigation links.
|
|
@@ -35,7 +47,22 @@ module Html2rss
|
|
|
35
47
|
# French
|
|
36
48
|
(a|à)\s+propos|(a|à)propos|contact|commentaires?|rejoindre|
|
|
37
49
|
se\s+connecter|login|membre|abonnement|recommand(e|é)(\s+pour\s+vous)?|
|
|
38
|
-
voir\s+tout|partager|s['’]inscrire|s['’]abonner|newsletter
|
|
50
|
+
voir\s+tout|partager|s['’]inscrire|s['’]abonner|newsletter|
|
|
51
|
+
# Italian
|
|
52
|
+
chi\s+siamo|contatti|commenti|accedi|iscriviti|condividi|abbonati|registrati|vedi\s+tutto|
|
|
53
|
+
consigliato(\s+per\s+te)?|abbonamento|
|
|
54
|
+
# Dutch
|
|
55
|
+
over\s+ons|contact|reacties|inloggen|aanmelden|delen|abonneren|registreren|bekijk\s+alles|
|
|
56
|
+
aanbevolen(\s+voor\s+jou)?|nieuwsbrief|lid\s+worden|lidmaatschap|
|
|
57
|
+
# Polish
|
|
58
|
+
o\s+nas|kontakt|komentarze|zaloguj\s+si(ę|e)|zarejestruj\s+si(ę|e)|udost(ę|e)pnij|subskrybuj|
|
|
59
|
+
zobacz\s+wszystko|polecane(\s+dla\s+ciebie)?|do(łą|la)cz|
|
|
60
|
+
# Swedish
|
|
61
|
+
om\s+oss|kontakt|kommentarer|logga\s+in|bli\s+medlem|dela|prenumerera|registrera\s+dig|visa\s+alla|
|
|
62
|
+
se\s+alla|rekommenderat(\s+f(ö|o)r\s+dig)?|nyhetsbrev|medlemskap|
|
|
63
|
+
# Japanese
|
|
64
|
+
会社概要|お問い合わせ|コメント|ログイン|会員登録|新規登録|シェア|共有|購読|すべて見る|全て見る|
|
|
65
|
+
もっと見る|おすすめ|ニュースレター|メルマガ
|
|
39
66
|
)\b
|
|
40
67
|
/ix
|
|
41
68
|
# Labels for recommendation chrome rather than source articles.
|
|
@@ -44,21 +71,90 @@ module Html2rss
|
|
|
44
71
|
recommended(\s+for\s+you)?|
|
|
45
72
|
empfohlen(\s+f(ue|ü)r\s+dich)?|
|
|
46
73
|
recomendado(\s+para\s+ti)?|
|
|
47
|
-
recommand(e|é)(\s+pour\s+vous)
|
|
74
|
+
recommand(e|é)(\s+pour\s+vous)?|
|
|
75
|
+
consigliato(\s+per\s+te)?|
|
|
76
|
+
aanbevolen(\s+voor\s+jou)?|
|
|
77
|
+
polecane(\s+dla\s+ciebie)?|
|
|
78
|
+
rekommenderat(\s+f(ö|o)r\s+dig)?|
|
|
79
|
+
おすすめ
|
|
48
80
|
)\b
|
|
49
81
|
/ix
|
|
50
82
|
|
|
83
|
+
# Whole read-more labels that stand alone ("Continue reading", "Lire la suite", "続きを読む").
|
|
84
|
+
READ_MORE_PHRASE = /
|
|
85
|
+
read\s+more|read\s+on|continue\s+reading|keep\s+reading|learn\s+more|find\s+out\s+more|
|
|
86
|
+
read\s+(?:the\s+)?(?:full\s+|whole\s+)?(?:story|article)|read\s+the\s+rest|
|
|
87
|
+
weiterlesen|weiter\s+lesen|mehr\s+lesen|mehr\s+erfahren|mehr\s+dazu|mehr\s+zum\s+thema|
|
|
88
|
+
lesen\s+sie\s+(?:mehr|weiter)|(?:den\s+)?(?:ganzen|gesamten|vollst(?:ä|a)ndigen)\s+artikel\s+lesen|
|
|
89
|
+
lire\s+la\s+suite|lire\s+plus|lire\s+l['’]article(?:\s+complet)?|en\s+savoir\s+plus|
|
|
90
|
+
pour\s+en\s+savoir\s+plus|plus\s+d['’]infos?|plus\s+d['’]informations?|
|
|
91
|
+
leer\s+m(?:á|a)s|seguir\s+leyendo|continuar\s+leyendo|saber\s+m(?:á|a)s|
|
|
92
|
+
m(?:á|a)s\s+informaci(?:ó|o)n|leer\s+(?:todo|el\s+art(?:í|i)culo\s+completo)|
|
|
93
|
+
leggi\s+tutto|leggi\s+di\s+pi(?:ù|u)|leggi\s+l['’]articolo|continua\s+a\s+leggere|scopri\s+di\s+pi(?:ù|u)|
|
|
94
|
+
maggiori\s+informazioni|ulteriori\s+informazioni|per\s+saperne\s+di\s+pi(?:ù|u)|approfondisci|
|
|
95
|
+
lees\s+meer|lees\s+verder|verder\s+lezen|meer\s+lezen|meer\s+informatie|meer\s+weten|
|
|
96
|
+
lees\s+(?:het\s+)?(?:hele|volledige)\s+artikel|
|
|
97
|
+
czytaj\s+wi(?:ę|e)cej|czytaj\s+dalej|dowiedz\s+si(?:ę|e)\s+wi(?:ę|e)cej|wi(?:ę|e)cej\s+informacji|
|
|
98
|
+
czytaj\s+ca(?:ł|l)o(?:ś|s)(?:ć|c)|zobacz\s+wi(?:ę|e)cej|
|
|
99
|
+
l(?:ä|a)s\s+mer|l(?:ä|a)s\s+vidare|forts(?:ä|a)tt\s+l(?:ä|a)sa|mer\s+information|
|
|
100
|
+
l(?:ä|a)s\s+hela\s+artikeln|
|
|
101
|
+
続きを読む|もっと読む|続きはこちら|詳しくはこちら|詳細を見る|詳細はこちら|全文を読む|続きを見る
|
|
102
|
+
/ix
|
|
103
|
+
# "More about X" lead-ins: a read-more phrase or a bare "more", then a topic preposition.
|
|
104
|
+
READ_MORE_TOPIC_LEAD = /
|
|
105
|
+
(?:#{READ_MORE_PHRASE.source}|more|mehr|plus\s+d['’]infos?|m(?:á|a)s|altro|di\s+pi(?:ù|u)|meer|wi(?:ę|e)cej|mer)
|
|
106
|
+
\s+(?:about|on|from|zu|zum\s+thema|zum|zur|(?:ü|ue)ber|von|sur|
|
|
107
|
+
(?:à|a)\s+propos\s+de|de|sobre|su|riguardo|over|van|
|
|
108
|
+
o|na\s+temat|om|av)
|
|
109
|
+
/ix
|
|
110
|
+
# A read-more label as a whole text, optionally wrapped in punctuation or arrows. The topic after a
|
|
111
|
+
# lead-in counts only when quoted ("More about «X»", „X“, "X", 'X', 「X」), so a headline that merely
|
|
112
|
+
# opens with one ("Mehr über die Zukunft der Rente") stays a headline; Japanese topic forms put the
|
|
113
|
+
# topic first.
|
|
114
|
+
READ_MORE_PATTERN = /
|
|
115
|
+
\A[\s\p{P}\p{S}]*(?:
|
|
116
|
+
(?:#{READ_MORE_PHRASE.source})|
|
|
117
|
+
(?:#{READ_MORE_TOPIC_LEAD.source})\s*[«„"“”'‘’‹「].+[»"“”'’›」]|
|
|
118
|
+
\S{1,40}(?:の続きを読む|をもっと読む|について詳しく(?:はこちら)?|の詳細(?:を見る|はこちら))
|
|
119
|
+
)[\s\p{P}\p{S}]*\z
|
|
120
|
+
/ix
|
|
121
|
+
|
|
122
|
+
# Visible new-window notice that trails a link label, in parentheses or brackets.
|
|
123
|
+
NEW_WINDOW_NOTICE = /
|
|
124
|
+
\s*[(\[(【]\s*(?:
|
|
125
|
+
opens\s+in\s+(?:a\s+)?new\s+(?:window|tab)|
|
|
126
|
+
(?:ö|oe)ffnet\s+(?:sich\s+)?in\s+(?:einem\s+neuen|neuem)\s+(?:fenster|tab)|
|
|
127
|
+
ouvre\s+dans\s+(?:une\s+)?nouvel(?:le)?\s+(?:fen(?:ê|e)tre|onglet)|
|
|
128
|
+
se\s+abre\s+en\s+(?:una\s+)?nueva\s+(?:ventana|pesta(?:ñ|n)a)|
|
|
129
|
+
si\s+apre\s+in\s+(?:una\s+)?nuova\s+(?:finestra|scheda)|
|
|
130
|
+
opent\s+in\s+(?:een\s+)?nieuw(?:e)?\s+(?:venster|tabblad)|
|
|
131
|
+
otwiera\s+si(?:ę|e)\s+w\s+nowym\s+(?:oknie|karcie)|
|
|
132
|
+
(?:ö|o)ppnas\s+i\s+(?:ett\s+)?nytt\s+(?:f(?:ö|o)nster|flik)|
|
|
133
|
+
新しい(?:ウィンドウ|ウインドウ|タブ)で開く
|
|
134
|
+
)\s*[)\])】]\s*\z
|
|
135
|
+
/ix
|
|
136
|
+
|
|
51
137
|
# @param text [String, #to_s] visible anchor text
|
|
52
138
|
# @return [Boolean] true when text matches a utility label
|
|
53
|
-
def utility?(text) = text.
|
|
139
|
+
def utility?(text) = normalize(text).match?(UTILITY_PATTERN)
|
|
54
140
|
|
|
55
141
|
# @param text [String, #to_s] visible anchor text
|
|
56
142
|
# @return [Boolean] true when text begins with a utility label
|
|
57
|
-
def utility_prefix?(text) = text.
|
|
143
|
+
def utility_prefix?(text) = normalize(text).match?(UTILITY_PREFIX_PATTERN)
|
|
58
144
|
|
|
59
145
|
# @param text [String, #to_s] visible anchor text
|
|
60
146
|
# @return [Boolean] true when text identifies recommendation chrome
|
|
61
|
-
def recommended?(text) = text.
|
|
147
|
+
def recommended?(text) = normalize(text).match?(RECOMMENDED_PATTERN)
|
|
148
|
+
|
|
149
|
+
# @param text [String, #to_s] visible anchor text or extracted title
|
|
150
|
+
# @return [Boolean] true when the whole text is a read-more label, bare or with its topic
|
|
151
|
+
def read_more?(text) = normalize(text).match?(READ_MORE_PATTERN)
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
# Every label is read in NFC, so a decomposed accent matches the precomposed vocabulary, and without
|
|
156
|
+
# its trailing new-window notice ("(opens in new tab)").
|
|
157
|
+
def normalize(text) = text.to_s.unicode_normalize(:nfc).sub(NEW_WINDOW_NOTICE, '')
|
|
62
158
|
end
|
|
63
159
|
end
|
|
64
160
|
end
|
data/lib/html2rss/mcp/README.md
CHANGED
|
@@ -39,6 +39,7 @@ Every tool result is one JSON object (text body and `structuredContent`):
|
|
|
39
39
|
| `next_step` | Bare verb name or `done` / `read_runtime` |
|
|
40
40
|
| `guidance` | Agent instruction (from `Outcome::Playbook`) |
|
|
41
41
|
| `payload` | Tool-specific data (`yaml`, `rss`, `items`, recon fields, …) |
|
|
42
|
+
| `cache` | Optional: `{ hits, max_age_seconds }` when cached pages fed the result |
|
|
42
43
|
|
|
43
44
|
`Contract.response` builds the wire body; `Outcome` owns `next_step` policy.
|
|
44
45
|
|
|
@@ -64,7 +65,7 @@ Do not duplicate playbook prose in `server.rb`.
|
|
|
64
65
|
| `html2rss://schema` | Full JSON Schema for feed configurations |
|
|
65
66
|
| `html2rss://extractors` | Registered extractor names |
|
|
66
67
|
| `html2rss://strategies` | Published MCP strategies (`auto`, `default`, `httpx`, `botasaurus`) |
|
|
67
|
-
| `html2rss://runtime` | `version`, `mcp_contract_version`, `catalog_fingerprint`, `tools`, `botasaurus_configured` (never the scraper URL). Fingerprint covers tool names, required keys, and `oneOf` branches; bump `mcp_contract_version` for envelope semantics. |
|
|
68
|
+
| `html2rss://runtime` | `version`, `mcp_contract_version`, `catalog_fingerprint`, `tools`, `botasaurus_configured`, `response_cache_ttl` (never the scraper URL). Fingerprint covers tool names, required keys, and `oneOf` branches; bump `mcp_contract_version` for envelope semantics. |
|
|
68
69
|
|
|
69
70
|
## Prompts
|
|
70
71
|
|
|
@@ -73,6 +74,17 @@ Do not duplicate playbook prose in `server.rb`.
|
|
|
73
74
|
| `scrape-webpage` | One `scrape` call; `inspect` only if weak or recon needed |
|
|
74
75
|
| `capture-feed-config` | Capture YAML → test → apply; catalog rewrite; strive enhance |
|
|
75
76
|
|
|
77
|
+
## Response cache
|
|
78
|
+
|
|
79
|
+
The MCP process is long-lived, so an agent iterating `inspect → capture → validate → test` on one URL should not refetch it. `RequestService::ResponseCache` is installed at server start, **on by default with a 300s TTL**. Set `HTML2RSS_RESPONSE_CACHE_TTL=<seconds>` to change it, or `0` / `off` / `false` to disable; an unusable value logs a warning and uses the default.
|
|
80
|
+
|
|
81
|
+
- Only initial 2xx fetches are cached, keyed by a digest of URL, strategy, headers, request options, and policy limits. Follow-ups and block pages always hit the network.
|
|
82
|
+
- Memory is bounded: at most 32 entries and 32 MiB of bodies, oldest evicted first.
|
|
83
|
+
- When a result was built from cached pages, the envelope carries `cache: { hits, max_age_seconds }`; absent means everything was fetched live. Wait out the TTL or restart with the cache off to force a refetch.
|
|
84
|
+
- `html2rss://runtime` publishes `response_cache_ttl` (`null` when off).
|
|
85
|
+
|
|
86
|
+
The one-shot CLI has no use for it; use `capture` or `--input -` for offline iteration.
|
|
87
|
+
|
|
76
88
|
## Strategy note
|
|
77
89
|
|
|
78
90
|
`scrape` / `capture` with `strategy: "auto"` run the full AutoFallback chain. `inspect` maps `auto` to default (HTTPX) for cheap diagnostics; pin `botasaurus` when you need browser rendering for inspect.
|