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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 06fbd552569edb4a645f262cd55a156e93e658421a497865ddae2e8e8b62ced9
|
|
4
|
+
data.tar.gz: d23cc7119baaa02d2994f169386a2dc12881032dc3c44975051b634a2fbbaf87
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0d92e97cf1eba6d0d05d1454340bcee34fc7a089641baeb446d9c0984d56b9e1827ccb82469bb28e592c5795b489de171d998da5bf94079600fecc5a29d6b2ee
|
|
7
|
+
data.tar.gz: 77d55816b01d3efb271bcb203fe964a7177df68a04dc87de3b243acc6be6b6c9ad5cb0d9fd4c9c2a8137767828c97a3a04da500f14d076364a9df133b85d1781
|
data/README.md
CHANGED
|
@@ -28,8 +28,8 @@ Cloud development: [Open in GitHub Codespaces](https://github.com/codespaces/new
|
|
|
28
28
|
1. **Config** — loads and validates configuration (YAML/hash); schema via `html2rss schema` / `schema/html2rss-config.schema.json`
|
|
29
29
|
2. **RequestService** — fetches pages (`default` (HTTPX), `botasaurus`, or `local_file`)
|
|
30
30
|
3. **Selectors** — extracts content via CSS selectors with extractors/post-processors
|
|
31
|
-
4. **AutoSource** — auto-detects content (Schema.org, JSON state, semantic HTML, structural patterns)
|
|
32
|
-
5. **FeedBuilder** — assembles Article objects and renders feeds (RSS 2.0 / JSON Feed 1.1)
|
|
31
|
+
4. **AutoSource** — auto-detects content (Schema.org, JSON state, semantic HTML, structural patterns); a card's `<audio>`/`<video>` file becomes its enclosure
|
|
32
|
+
5. **FeedBuilder** — assembles Article objects and renders feeds (RSS 2.0 / JSON Feed 1.1), with podcast tags for audio/video feeds
|
|
33
33
|
|
|
34
34
|
```text
|
|
35
35
|
Config -> Request -> Extraction -> Processing -> Building -> Output
|
|
@@ -65,6 +65,8 @@ html2rss scrape https://example.com/news --format jsonfeed --explain
|
|
|
65
65
|
# Off-domain item URLs are dropped by default. --keep-different-domain opts in
|
|
66
66
|
# and lets the remote source control published links to unrelated domains.
|
|
67
67
|
html2rss scrape https://example.com/app-list.json --keep-different-domain
|
|
68
|
+
# Items the page marks as sponsored are dropped by default. --keep-sponsored opts in.
|
|
69
|
+
html2rss scrape https://example.com/news --keep-sponsored
|
|
68
70
|
|
|
69
71
|
# Durable config workflow
|
|
70
72
|
html2rss capture https://example.com/news --write feed.yml
|
|
@@ -11,11 +11,14 @@ module Html2rss
|
|
|
11
11
|
# description metadata. If none of these identifiers are available it
|
|
12
12
|
# defaults to the article object's hash to preserve the original entry.
|
|
13
13
|
class Deduplicator
|
|
14
|
+
# Raised when the deduplicator is built without an article list.
|
|
15
|
+
class InvalidError < Html2rss::ContractError; end
|
|
16
|
+
|
|
14
17
|
##
|
|
15
18
|
# @param articles [Array<Html2rss::Article>]
|
|
16
|
-
# @raise [
|
|
19
|
+
# @raise [InvalidError] if articles are not provided
|
|
17
20
|
def initialize(articles)
|
|
18
|
-
raise
|
|
21
|
+
raise InvalidError, 'articles must be provided' unless articles
|
|
19
22
|
|
|
20
23
|
@articles = articles
|
|
21
24
|
end
|
|
@@ -6,43 +6,203 @@ module Html2rss
|
|
|
6
6
|
class Article
|
|
7
7
|
##
|
|
8
8
|
# Represents an enclosure attached to an article (RSS / JSON Feed media).
|
|
9
|
+
#
|
|
10
|
+
# The one owner of media-file facts: URL, MIME type, byte length and playback duration,
|
|
11
|
+
# the media-kind predicates feed builders choose by, and the parsers that turn
|
|
12
|
+
# publisher-declared size and duration strings into those facts.
|
|
9
13
|
class Enclosure
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
14
|
+
# Raised when an enclosure has no absolute http(s) URL or declares an unknown kind.
|
|
15
|
+
class InvalidError < Html2rss::ContractError; end
|
|
16
|
+
|
|
17
|
+
# A byte size: a number with an optional unit (+12345+, +850 KB+, +12.3 MB+, +1.5GiB+). The whitespace
|
|
18
|
+
# runs are possessive, so a long run of spaces never backtracks.
|
|
19
|
+
SIZE = /\A\s*+(\d+(?:\.\d+)?)\s*+([kmgt]i?b|b|bytes?)?\s*+\z/i
|
|
20
|
+
# Bytes per size unit; SI prefixes count in thousands, binary (+KiB+) in 1024s.
|
|
21
|
+
SIZE_UNITS = {
|
|
22
|
+
'b' => 1, 'byte' => 1, 'bytes' => 1,
|
|
23
|
+
'kb' => 1000, 'mb' => 1000**2, 'gb' => 1000**3, 'tb' => 1000**4,
|
|
24
|
+
'kib' => 1024, 'mib' => 1024**2, 'gib' => 1024**3, 'tib' => 1024**4
|
|
25
|
+
}.freeze
|
|
26
|
+
# An ISO 8601 duration of days and time parts (+PT1H2M3S+, +PT45M+, +P1DT2H+).
|
|
27
|
+
DURATION = /\AP(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?\z/
|
|
28
|
+
# A clock duration (+itunes:duration+, Media RSS): +HH:MM:SS+, +MM:SS+ or plain seconds.
|
|
29
|
+
CLOCK = /\A(?:(?:(\d+):)?(\d+):)?(\d+(?:\.\d+)?)\z/
|
|
30
|
+
# Types of web pages (embed players, article pages), never a media file: HTML, XHTML and the
|
|
31
|
+
# types MIME::Types guesses from server-page script extensions (+.php+, +.asp+, +.aspx+, +.jsp+,
|
|
32
|
+
# +.cgi+, +.cfm+). Other +text/*+ files (+.txt+, +.csv+, +.ics+, +.vtt+) are downloads.
|
|
33
|
+
PAGE_TYPES = %w[
|
|
34
|
+
text/html application/xhtml+xml text/x-php application/x-httpd-php text/asp text/aspdotnet text/x-jsp
|
|
35
|
+
text/x-cgi text/x-coldfusion
|
|
36
|
+
].freeze
|
|
37
|
+
# Types of stream playlists (HLS, DASH) and Flash players: played through a page or player, never
|
|
38
|
+
# downloaded as a file.
|
|
39
|
+
STREAM_TYPES = %w[
|
|
40
|
+
application/vnd.apple.mpegurl application/x-mpegurl application/dash+xml application/x-shockwave-flash
|
|
41
|
+
].freeze
|
|
42
|
+
# The type of a URL that declares none and has no known extension.
|
|
43
|
+
UNTYPED = 'application/octet-stream'
|
|
44
|
+
# Playable audio/video types by file extension. MIME::Types lists these extensions under
|
|
45
|
+
# several types and puts a non-playable or codec-level one first (+.mp4+ as +application/mp4+,
|
|
46
|
+
# +.ogg+ as +audio/vorbis+, +.m4v+ without +video/mp4+), so media extensions are looked up here first.
|
|
47
|
+
# HLS playlists (+.m3u8+) are no media file: MIME::Types types them +application/vnd.apple.mpegurl+.
|
|
48
|
+
MEDIA_TYPES = {
|
|
49
|
+
'mp4' => 'video/mp4', 'm4v' => 'video/mp4', 'ogv' => 'video/ogg', 'webm' => 'video/webm',
|
|
50
|
+
'm4a' => 'audio/mp4', 'mp3' => 'audio/mpeg', 'ogg' => 'audio/ogg', 'oga' => 'audio/ogg',
|
|
51
|
+
'opus' => 'audio/opus', 'aac' => 'audio/aac', 'wav' => 'audio/wav', 'flac' => 'audio/flac'
|
|
52
|
+
}.freeze
|
|
53
|
+
# The type of a file whose source proves its kind (an audio or video player, a Schema.org media object,
|
|
54
|
+
# a Media RSS +medium+) while its URL names no type (no or an unknown extension, or a server page): the
|
|
55
|
+
# most widely used format of that kind, since an RSS enclosure needs a concrete type and +audio/*+ is none.
|
|
56
|
+
KIND_TYPES = { audio: 'audio/mpeg', video: 'video/mp4', image: 'image/jpeg' }.freeze
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
##
|
|
60
|
+
# Guesses the content type based on the file extension of the URL: a media extension
|
|
61
|
+
# ({MEDIA_TYPES}) gives its audio or video type, any other the first MIME::Types match.
|
|
62
|
+
#
|
|
63
|
+
# @param url [Html2rss::Url]
|
|
64
|
+
# @param default [String] default content type
|
|
65
|
+
# @return [String] guessed content type, or default
|
|
66
|
+
def guess_content_type_from_url(url, default: UNTYPED)
|
|
67
|
+
return default unless url
|
|
68
|
+
|
|
69
|
+
path = url.path
|
|
70
|
+
MEDIA_TYPES[File.extname(path).delete_prefix('.').downcase] ||
|
|
71
|
+
MIME::Types.type_for(path).first&.content_type || default
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
##
|
|
75
|
+
# Parses a declared file size (Schema.org +contentSize+ and alike) into bytes.
|
|
76
|
+
#
|
|
77
|
+
# @param value [String, Integer, nil] e.g. +"12.3 MB"+, +"850 KB"+, +"12345"+
|
|
78
|
+
# @return [Integer, nil] positive byte count, or nil when unparseable
|
|
79
|
+
def parse_size(value)
|
|
80
|
+
return unless (match = SIZE.match(value.to_s))
|
|
81
|
+
|
|
82
|
+
bytes = (Float(match[1]) * SIZE_UNITS.fetch(match[2].to_s.downcase, 1)).round
|
|
83
|
+
bytes if bytes.positive?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
##
|
|
87
|
+
# Parses a declared playback duration into seconds: ISO 8601 (Schema.org +duration+) or a
|
|
88
|
+
# clock (+itunes:duration+, Media RSS +duration+).
|
|
89
|
+
#
|
|
90
|
+
# @param value [String, Integer, nil] e.g. +"PT1H2M3S"+, +"PT45M"+, +"1:02:03"+, +"45:00"+, +"2700"+
|
|
91
|
+
# @return [Integer, nil] positive seconds, or nil when unparseable
|
|
92
|
+
def parse_duration(value)
|
|
93
|
+
return unless (parts = duration_parts(value.to_s.strip.upcase))
|
|
94
|
+
|
|
95
|
+
days, hours, minutes, seconds = parts.map(&:to_f)
|
|
96
|
+
total = ((days * 86_400) + (hours * 3600) + (minutes * 60) + seconds).round
|
|
97
|
+
total if total.positive?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# @return [Array<String, nil>, nil] days, hours, minutes and seconds, or nil when no duration shape matches
|
|
103
|
+
def duration_parts(text)
|
|
104
|
+
DURATION.match(text)&.captures || CLOCK.match(text)&.captures&.unshift(nil)
|
|
105
|
+
end
|
|
25
106
|
end
|
|
26
107
|
|
|
27
|
-
# @param url [Html2rss::Url] absolute enclosure URL
|
|
108
|
+
# @param url [Html2rss::Url] absolute http(s) enclosure URL ({Url#web?}); sources resolve declared references
|
|
109
|
+
# with {Url.resolve_web} and drop the rest, so no feed format ships a +javascript:+, +data:+ or +ftp:+ file
|
|
28
110
|
# @param type [String, nil] optional enclosure MIME type
|
|
29
|
-
# @param bytes_length [Integer] enclosure length in bytes
|
|
30
|
-
|
|
31
|
-
|
|
111
|
+
# @param bytes_length [Integer, nil] enclosure length in bytes; nil when unknown
|
|
112
|
+
# @param duration [Integer, nil] playback length in seconds, kept only for an audio or video file ({#media?});
|
|
113
|
+
# nil (or not positive) when unknown
|
|
114
|
+
# @param kind [Symbol, nil] +:audio+, +:video+ or +:image+ when its source proves the file's kind
|
|
115
|
+
# ({KIND_TYPES}); nil when unknown
|
|
116
|
+
# @raise [InvalidError] when the URL is not an absolute http(s) URL or the kind is unknown
|
|
117
|
+
def initialize(url:, type: nil, bytes_length: nil, duration: nil, kind: nil)
|
|
118
|
+
raise InvalidError, 'An Enclosure requires an absolute http(s) URL' unless url&.web?
|
|
32
119
|
|
|
33
120
|
@url = url
|
|
34
|
-
@
|
|
35
|
-
@
|
|
121
|
+
@bytes_length = bytes_length || 0
|
|
122
|
+
@type = resolved_type(type, kind_type(kind))
|
|
123
|
+
@mime = @type.downcase[/\A[^;\s]*/]
|
|
124
|
+
@duration = duration if duration.instance_of?(Integer) && duration.positive? && media?
|
|
36
125
|
end
|
|
37
126
|
|
|
38
|
-
# @return [String] explicit MIME type
|
|
39
|
-
|
|
127
|
+
# @return [String] explicit MIME type, else one inferred from the URL extension; a file of a known kind whose
|
|
128
|
+
# URL names no media type takes that kind's type ({KIND_TYPES}), also when it is declared {UNTYPED}
|
|
129
|
+
attr_reader :type
|
|
40
130
|
|
|
41
|
-
# @return [Integer] enclosure length in bytes
|
|
131
|
+
# @return [Integer] enclosure length in bytes; 0 when unknown
|
|
42
132
|
attr_reader :bytes_length
|
|
43
133
|
|
|
44
|
-
# @return [
|
|
134
|
+
# @return [Integer, nil] playback length in seconds of an audio or video file; nil for any other file
|
|
135
|
+
attr_reader :duration
|
|
136
|
+
|
|
137
|
+
# @return [Html2rss::Url] absolute http(s) enclosure URL
|
|
45
138
|
attr_reader :url
|
|
139
|
+
|
|
140
|
+
# @return [Boolean] whether the file is audio
|
|
141
|
+
def audio? = mime.start_with?('audio/')
|
|
142
|
+
|
|
143
|
+
# @return [Boolean] whether the file is video
|
|
144
|
+
def video? = mime.start_with?('video/')
|
|
145
|
+
|
|
146
|
+
# @return [Boolean] whether the file is audio or video
|
|
147
|
+
def media? = audio? || video?
|
|
148
|
+
|
|
149
|
+
# @return [Boolean] whether the file is an image
|
|
150
|
+
def image? = mime.start_with?('image/')
|
|
151
|
+
|
|
152
|
+
# @return [Boolean] whether the URL is a web page ({PAGE_TYPES}: HTML, XHTML, a server page), such as an
|
|
153
|
+
# embed player
|
|
154
|
+
def page? = PAGE_TYPES.include?(mime)
|
|
155
|
+
|
|
156
|
+
# @return [Boolean] whether the URL path ends in a file extension (+.pdf+), unlike a watch page (+/watch/42+)
|
|
157
|
+
def file_extension? = !File.extname(url.path).empty?
|
|
158
|
+
|
|
159
|
+
# A watch or player page (+/watch/42+) declared as a file: a web page ({#page?}), or an untyped URL
|
|
160
|
+
# ({UNTYPED}: no declared type and no known file extension) with no file extension and no declared
|
|
161
|
+
# byte length. A declared length makes an extensionless URL a file (+/download/42+), since only a
|
|
162
|
+
# file has one.
|
|
163
|
+
#
|
|
164
|
+
# @return [Boolean] whether the URL is a watch or player page rather than a file
|
|
165
|
+
def player_page?
|
|
166
|
+
page? || (mime == UNTYPED && !file_extension? && bytes_length.zero?)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# A file a feed reader can download: no stream playlist or Flash player ({STREAM_TYPES}), and no watch
|
|
170
|
+
# or player page ({#player_page?}). {Article#enclosures} keeps only files, so no feed format ships the rest.
|
|
171
|
+
#
|
|
172
|
+
# @return [Boolean] whether the URL is a downloadable file
|
|
173
|
+
def file? = !(STREAM_TYPES.include?(mime) || player_page?)
|
|
174
|
+
|
|
175
|
+
# A downloadable document or archive (PDF, ZIP): a file ({#file?}) that is no media file or image.
|
|
176
|
+
#
|
|
177
|
+
# @return [Boolean] whether the URL is a downloadable document or archive
|
|
178
|
+
def document? = file? && !media? && !image?
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
# @return [String] the lowercase MIME type without parameters (+audio/ogg; codecs=opus+ is +audio/ogg+)
|
|
183
|
+
attr_reader :mime
|
|
184
|
+
|
|
185
|
+
# @param kind [Symbol, nil]
|
|
186
|
+
# @return [String, nil] the type a file of that kind takes when its URL names none ({KIND_TYPES})
|
|
187
|
+
# @raise [InvalidError] for a kind other than +:audio+, +:video+ or +:image+
|
|
188
|
+
def kind_type(kind)
|
|
189
|
+
return unless kind
|
|
190
|
+
|
|
191
|
+
KIND_TYPES.fetch(kind) { raise InvalidError, "Unknown enclosure kind: #{kind.inspect}" }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The declared type, else the type guessed from the URL; a file of a known kind whose URL names no type
|
|
195
|
+
# (declared {UNTYPED}, or guessed untyped or a server page) takes that kind's type ({KIND_TYPES}) instead.
|
|
196
|
+
#
|
|
197
|
+
# @param declared [String, nil]
|
|
198
|
+
# @param kind_type [String, nil]
|
|
199
|
+
# @return [String]
|
|
200
|
+
def resolved_type(declared, kind_type)
|
|
201
|
+
return declared if declared && !(kind_type && declared.casecmp?(UNTYPED))
|
|
202
|
+
|
|
203
|
+
guessed = self.class.guess_content_type_from_url(url)
|
|
204
|
+
kind_type && (guessed == UNTYPED || PAGE_TYPES.include?(guessed)) ? kind_type : guessed
|
|
205
|
+
end
|
|
46
206
|
end
|
|
47
207
|
end
|
|
48
208
|
end
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Html2rss
|
|
4
|
+
class Article
|
|
5
|
+
##
|
|
6
|
+
# Owns article title composition and the one title-noise vocabulary.
|
|
7
|
+
#
|
|
8
|
+
# Whitespace collapse ({.collapse}) is universal and also drops invisible characters ({INVISIBLE}).
|
|
9
|
+
# Composition ({.compose}) is opt-in per caller (see {Article#initialize}): configured selector titles
|
|
10
|
+
# and publisher feed titles stay verbatim.
|
|
11
|
+
#
|
|
12
|
+
# A composed title takes the form +Kicker: Headline+ (or just +Headline+, or the kicker alone when
|
|
13
|
+
# the headline is blank). Both parts are whitespace-collapsed and trimmed of stray separators
|
|
14
|
+
# ({SEPARATOR_MARK}). Only the kicker is noise-stripped by text: a leading clock time set off by a
|
|
15
|
+
# separator (+10:00 | Topic+), screen-reader field labels (+Section:+) and type labels with their
|
|
16
|
+
# marker (+Article type: Video+). The label and marker sets are small, closed and multilingual
|
|
17
|
+
# (en, de, fr, es, it, nl, pl, sv, ja), matching {LinkDestination::TextClassifier}.
|
|
18
|
+
#
|
|
19
|
+
# A Japanese headline may open with a bracket label (+【速報】+, +<動画>+): one naming a content type
|
|
20
|
+
# ({TYPE_MARKERS}) is removed, any other leading bracket text becomes the kicker ({LEADING_BRACKET}).
|
|
21
|
+
#
|
|
22
|
+
# Headline text is never noise-stripped: +20:15 Uhr: Neuer Krimi+ or +9:41 is the time+ is a real
|
|
23
|
+
# headline. Teaser noise in a headline is told apart by structure instead ({Html::TitlePieces}):
|
|
24
|
+
# date and time elements leave wherever they sit outside running text, elements that are only a
|
|
25
|
+
# clock or a label ({.headline_noise_piece?}) only before the first headline word, and kicker
|
|
26
|
+
# pieces are judged with {.noise_piece?}.
|
|
27
|
+
module Title
|
|
28
|
+
# Joins a kicker to its headline.
|
|
29
|
+
SEPARATOR = ': '
|
|
30
|
+
# Characters that render nothing inside a word: soft hyphen, zero-width space, word joiner and
|
|
31
|
+
# byte-order mark. Every title drops them, verbatim publisher titles included. Zero-width non-joiner
|
|
32
|
+
# and joiner (U+200C, U+200D) stay: they shape Persian, Urdu and Indic spelling and join emoji.
|
|
33
|
+
INVISIBLE = /[\u00AD\u200B\u2060\uFEFF]/
|
|
34
|
+
# Clock time of day (24h, or 12h with AM/PM). Description chip peeling reuses it. Case-sensitive:
|
|
35
|
+
# a spaced lowercase +am+ is the German preposition (+9:15 am Bahnhof+), so only +9:15am+ takes it.
|
|
36
|
+
CLOCK = /(?:[01]?\d|2[0-3]):[0-5]\d(?::[0-5]\d)?(?:\s*(?:AM|PM)|am|pm)?/
|
|
37
|
+
# A whole piece that is only a clock time, optionally with +Uhr+ or one timezone/offset token
|
|
38
|
+
# (IANA, GMT/UTC offset, or abbreviation). Description peels it as a chip suffix too.
|
|
39
|
+
CLOCK_PIECE = %r{
|
|
40
|
+
\A
|
|
41
|
+
#{CLOCK}
|
|
42
|
+
(?:\s+(?:[A-Za-z]+(?:/[A-Za-z_]+)+|(?:GMT|UTC)[+-]\d{1,2}(?::\d{2})?|[A-Za-z]{2,5}))?
|
|
43
|
+
\z
|
|
44
|
+
}ix
|
|
45
|
+
# A whole piece that is only a Japanese calendar date (+11月12日+), optionally with a year and a clock time
|
|
46
|
+
# (+11月12日 16:42+).
|
|
47
|
+
DATE_PIECE = /\A(?:\d{4}年)?\d{1,2}月\d{1,2}日(?:\s+#{CLOCK})?\z/
|
|
48
|
+
# Content-type markers (lowercase). Description drops them as whole leftover lines.
|
|
49
|
+
TYPE_MARKERS = Set[
|
|
50
|
+
'news', 'news article', 'press release', 'video', 'audio', 'podcast', 'gallery', 'live', 'live blog',
|
|
51
|
+
'full episode', 'bildergalerie', 'fotostrecke', 'liveticker', 'pressemitteilung', 'ganze folge',
|
|
52
|
+
'vidéo', 'diaporama', 'en direct', 'communiqué de presse', 'épisode complet',
|
|
53
|
+
'vídeo', 'galería', 'en directo', 'nota de prensa', 'episodio completo',
|
|
54
|
+
'diretta', 'in diretta', 'diretta streaming', 'fotogallery', 'galleria', 'comunicato stampa',
|
|
55
|
+
'puntata completa',
|
|
56
|
+
'liveblog', 'fotogalerij', 'persbericht', 'volledige aflevering',
|
|
57
|
+
'wideo', 'galeria', 'na żywo', 'relacja na żywo', 'komunikat prasowy', 'cały odcinek',
|
|
58
|
+
'bildspel', 'direkt', 'direktsändning', 'liverapport', 'livesändning', 'pressmeddelande', 'hela avsnittet',
|
|
59
|
+
'速報', '詳報', '独自', '動画', '写真', '中継', '生中継', 'ライブ', 'プレスリリース'
|
|
60
|
+
].freeze
|
|
61
|
+
# The markers as one alternation, longest first so +live blog+ wins over +live+.
|
|
62
|
+
MARKER_ALTERNATION = Regexp.union(TYPE_MARKERS.sort_by { -_1.length }).source
|
|
63
|
+
# One separator between a kicker and its headline: +: | • · – — - / > » › →+, each only when
|
|
64
|
+
# followed by whitespace, so a sign (+–5 Grad+), a path (+/e/+) or a German »quote« is not one, nor
|
|
65
|
+
# a comparison before a number (+> 50 Prozent+).
|
|
66
|
+
SEPARATOR_MARK = %r{(?:[:|•·–—/»›→-]|>(?![[:space:]]*\d))(?=[[:space:]]|\z)}
|
|
67
|
+
# Screen-reader field labels (+Section:+, +Artikel:+) and type labels with their marker
|
|
68
|
+
# (+Article type: Video+). Each field label needs its colon: a bare +Article+ is a word. A letter with
|
|
69
|
+
# an accented variant is an alternation, not a class: with +/i+, Ruby on Linux does not fold an
|
|
70
|
+
# uppercase accented letter (+Ä+) against a class that also holds its ASCII base (+[äa]+).
|
|
71
|
+
LABEL = /
|
|
72
|
+
(?:section|category|topic|rubrik|ressort|kategorie|thema|rubrique|cat(?:é|e)gorie|th(?:è|e)me|secci(?:ó|o)n
|
|
73
|
+
|categor(?:í|i)a|tema|sezione|categoria|argomento|rubrica|sectie|categorie|onderwerp|rubriek
|
|
74
|
+
|sekcja|kategoria|temat|dzia(?:ł|l)|sektion|kategori|(?:ä|a)mne
|
|
75
|
+
|article|artikel|art(?:í|i)culo|articolo|artyku(?:ł|l)
|
|
76
|
+
|セクション|カテゴリー?|ジャンル|分類|記事)\s*[::]
|
|
77
|
+
| (?:article\s+type|content\s+type|artikeltyp|beitragstyp|inhaltstyp|type\s+d['’]article
|
|
78
|
+
|type\s+de\s+contenu|tipo\s+de\s+art(?:í|i)culo|tipo\s+de\s+contenido
|
|
79
|
+
|tipo\s+di\s+articolo|tipo\s+di\s+contenuto|artikeltype|inhoudstype|soort\s+artikel
|
|
80
|
+
|typ\s+artyku(?:ł|l)u|typ\s+tre(?:ś|s)ci|rodzaj\s+tre(?:ś|s)ci|innehållstyp
|
|
81
|
+
|記事の種類|記事タイプ|コンテンツの種類)\s*[::]
|
|
82
|
+
(?:\s*(?:#{MARKER_ALTERNATION})(?![[:alnum:]]))?
|
|
83
|
+
/ix
|
|
84
|
+
# A whole piece that is only a label: a screen-reader label element, never headline text.
|
|
85
|
+
LABEL_PIECE = /\A(?:#{LABEL})\z/
|
|
86
|
+
# Leading separators of a headline (+Kicker</span> – Headline+ leaves +– Headline+).
|
|
87
|
+
LEADING_SEPARATORS = /\A(?:[[:space:]]|#{SEPARATOR_MARK})+/
|
|
88
|
+
# A leading Japanese bracket (+【…】+ or +<…>+) of one to thirty characters, with headline text after it.
|
|
89
|
+
LEADING_BRACKET = /\A[【<][[:space:]]*([^】>]{1,30}?)[[:space:]]*[】>][[:space:]]*(?=\S)/
|
|
90
|
+
# A bracket naming a content type (+【速報】+): noise as a kicker.
|
|
91
|
+
BRACKET_LABEL = /[【<][[:space:]]*(?:#{MARKER_ALTERNATION})[[:space:]]*[】>]/i
|
|
92
|
+
# Leading kicker noise, repeated: separators, separated clocks, labels.
|
|
93
|
+
KICKER_NOISE = /
|
|
94
|
+
\A(?:
|
|
95
|
+
[[:space:]] | #{SEPARATOR_MARK} | #{LABEL} | #{BRACKET_LABEL}
|
|
96
|
+
| #{CLOCK}(?:\s*Uhr)?(?=[[:space:]]*(?:#{SEPARATOR_MARK}|\z))
|
|
97
|
+
)+
|
|
98
|
+
/x
|
|
99
|
+
# Trailing stray separators (+Kicker :+, +Kicker -+, +Kicker ›+). A trailing +»+ closes a French
|
|
100
|
+
# quote, so it stays.
|
|
101
|
+
TRAILING_SEPARATORS = %r{(?:[[:space:]]|[:|•·–—]|(?<=[[:space:]])[-/>›→])+\z}
|
|
102
|
+
# The inline kicker boundary of +Kicker : Headline+: a whitespace-preceded colon after at most
|
|
103
|
+
# eight words ending in a letter or closing quote/bracket, so a score (+16 : 9+) keeps its colon.
|
|
104
|
+
SPACED_COLON = /\A((?:[^[:space:]]+[[:space:]]+){0,7}?[^[:space:]]*[\p{L}"'“”’»›)\]])[[:space:]]+:[[:space:]]*/
|
|
105
|
+
# A kicker that already ends a sentence (+Kicker?+) joins its headline with a space, not a colon.
|
|
106
|
+
TERMINAL_PUNCTUATION = /[?!…‽]\z/
|
|
107
|
+
private_constant :MARKER_ALTERNATION, :LEADING_BRACKET, :BRACKET_LABEL, :LABEL, :LABEL_PIECE,
|
|
108
|
+
:LEADING_SEPARATORS, :KICKER_NOISE, :TRAILING_SEPARATORS, :SPACED_COLON, :TERMINAL_PUNCTUATION
|
|
109
|
+
|
|
110
|
+
class << self
|
|
111
|
+
##
|
|
112
|
+
# @param text [String, nil] title text as extracted
|
|
113
|
+
# @return [String, nil] +text+ in NFC (a composed and a decomposed +Ä+ are one title) without
|
|
114
|
+
# {INVISIBLE} characters, whitespace runs (newlines included) collapsed and trimmed, or nil when blank
|
|
115
|
+
def collapse(text)
|
|
116
|
+
collapsed = text.to_s.unicode_normalize
|
|
117
|
+
collapsed = collapsed.gsub(INVISIBLE, '') if collapsed.match?(INVISIBLE)
|
|
118
|
+
collapsed = collapsed.gsub(/[[:space:]]+/, ' ').strip
|
|
119
|
+
collapsed unless collapsed.empty?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
##
|
|
123
|
+
# @param headline [String, nil] headline text as extracted; kept verbatim apart from separators
|
|
124
|
+
# @param kicker [String, nil] kicker / eyebrow text, when the source exposes one
|
|
125
|
+
# @return [String, nil] +Kicker: Headline+, the headline, the kicker when the headline is blank,
|
|
126
|
+
# or nil when both are blank
|
|
127
|
+
def compose(headline, kicker: nil)
|
|
128
|
+
head, top = peel_bracket(trim(headline, LEADING_SEPARATORS), trim(kicker, KICKER_NOISE))
|
|
129
|
+
return head if top.nil? || noise_kicker?(top)
|
|
130
|
+
return top unless head
|
|
131
|
+
return head if prefixed?(head, top)
|
|
132
|
+
|
|
133
|
+
"#{top}#{top.match?(TERMINAL_PUNCTUATION) ? ' ' : SEPARATOR}#{head}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
##
|
|
137
|
+
# Whether a piece of text is only a clock time (+19:47+, +10:00 CET+). Description peels such chips.
|
|
138
|
+
#
|
|
139
|
+
# @param text [String, nil] visible text of one element or chip
|
|
140
|
+
# @return [Boolean]
|
|
141
|
+
def clock_piece?(text) = collapse(text).to_s.match?(CLOCK_PIECE)
|
|
142
|
+
|
|
143
|
+
##
|
|
144
|
+
# Whether a piece of text is only a calendar date or a date with a clock time (+11月12日 16:42+).
|
|
145
|
+
# {Html::TitlePieces} drops such elements at either edge of the headline.
|
|
146
|
+
#
|
|
147
|
+
# @param text [String, nil] visible text of one headline descendant element
|
|
148
|
+
# @return [Boolean]
|
|
149
|
+
def date_piece?(text) = collapse(text).to_s.match?(DATE_PIECE)
|
|
150
|
+
|
|
151
|
+
##
|
|
152
|
+
# Whether one element inside a headline is teaser noise on its own: only a clock time or only a
|
|
153
|
+
# label (+Section:+, +Article type: Video+). {Html::TitlePieces} drops such elements when they open
|
|
154
|
+
# the headline; the headline text itself is never noise-stripped.
|
|
155
|
+
#
|
|
156
|
+
# @param text [String, nil] visible text of one headline descendant element
|
|
157
|
+
# @return [Boolean]
|
|
158
|
+
def headline_noise_piece?(text)
|
|
159
|
+
piece = collapse(text).to_s
|
|
160
|
+
piece.match?(CLOCK_PIECE) || piece.match?(DATE_PIECE) || piece.match?(LABEL_PIECE)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
##
|
|
164
|
+
# Whether one child element of a kicker is noise on its own: blank, a clock time, a closed field
|
|
165
|
+
# label (+Thema:+, +Section:+) or a type marker. Any other piece ending in a colon is kicker text
|
|
166
|
+
# (+Conflict:+); {.compose} trims that colon. {Html::TitlePieces} drops noise kicker pieces.
|
|
167
|
+
#
|
|
168
|
+
# @param text [String, nil] visible text of one kicker child element
|
|
169
|
+
# @return [Boolean]
|
|
170
|
+
def noise_piece?(text)
|
|
171
|
+
piece = collapse(text)
|
|
172
|
+
piece.nil? || piece.match?(LABEL_PIECE) || piece.match?(CLOCK_PIECE) || TYPE_MARKERS.include?(piece.downcase)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def trim(text, prefix)
|
|
178
|
+
return unless (collapsed = collapse(text))
|
|
179
|
+
|
|
180
|
+
trimmed = collapsed.sub(prefix, '').sub(TRAILING_SEPARATORS, '').sub(SPACED_COLON, "\\1#{SEPARATOR}")
|
|
181
|
+
trimmed unless trimmed.empty?
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# A leading bracket naming a content type leaves the headline; any other bracket text is the kicker
|
|
185
|
+
# unless the source already gave one (then the headline stays whole).
|
|
186
|
+
def peel_bracket(head, top)
|
|
187
|
+
return [head, top] unless (bracket = head&.match(LEADING_BRACKET))
|
|
188
|
+
return [bracket.post_match, top] if TYPE_MARKERS.include?(bracket[1].downcase)
|
|
189
|
+
return [head, top] if top
|
|
190
|
+
|
|
191
|
+
[bracket.post_match, bracket[1]]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def noise_kicker?(kicker) = TYPE_MARKERS.include?(kicker.downcase)
|
|
195
|
+
|
|
196
|
+
# Whether the headline already opens with the kicker as a whole word.
|
|
197
|
+
def prefixed?(head, kicker)
|
|
198
|
+
head[0, kicker.length].casecmp?(kicker) && !head[kicker.length].to_s.match?(/[[:alnum:]]/)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
data/lib/html2rss/article.rb
CHANGED
|
@@ -6,8 +6,12 @@ module Html2rss
|
|
|
6
6
|
##
|
|
7
7
|
# Article is a simple data object representing an article extracted from a page.
|
|
8
8
|
#
|
|
9
|
+
# Article owns the title via {Article::Title}. Every title is whitespace-collapsed; callers that extract titles
|
|
10
|
+
# from heuristic card markup opt in with +compose_title: true+ to join +kicker+ to +title+ (the headline) and strip
|
|
11
|
+
# noise. Configured selector titles and publisher-declared headlines (feeds, structured data) stay verbatim.
|
|
12
|
+
#
|
|
9
13
|
# Description and enclosure wire presentation live in {FeedBuilder::ItemPresentation}.
|
|
10
|
-
class Article
|
|
14
|
+
class Article # rubocop:disable Metrics/ClassLength -- one reader per article attribute
|
|
11
15
|
# Allowed article attributes accepted by the value object constructor.
|
|
12
16
|
PROVIDED_KEYS = %i[id title description url image author guid published_at enclosures categories scraper].freeze
|
|
13
17
|
# Separator used to build deterministic deduplication fingerprints.
|
|
@@ -19,36 +23,65 @@ module Html2rss
|
|
|
19
23
|
NOT_SET = Object.new.freeze
|
|
20
24
|
|
|
21
25
|
# @param id [String, nil] stable article identifier
|
|
22
|
-
# @param title [String, nil] article
|
|
26
|
+
# @param title [String, nil] article headline
|
|
27
|
+
# @param kicker [String, nil] kicker / eyebrow text composed into the title (not stored); used only with
|
|
28
|
+
# +compose_title+
|
|
23
29
|
# @param description [String, nil] raw extracted description/content (not feed-rendered HTML)
|
|
24
30
|
# @param url [String, Html2rss::Url, nil] canonical article URL
|
|
25
|
-
# @param image [String, Html2rss::Url, nil] image URL for description / JSON Feed +image
|
|
31
|
+
# @param image [String, Html2rss::Url, nil] image URL for description / JSON Feed +image+; without one, the
|
|
32
|
+
# first image file among +enclosures+ ({Enclosure#image?}) is the item image
|
|
26
33
|
# @param author [String, nil] author name
|
|
27
34
|
# @param guid [String, Array, nil] explicit GUID override
|
|
28
35
|
# @param published_at [String, Time, DateTime, nil] publication timestamp
|
|
29
36
|
# @param enclosures [Array<Hash{Symbol => Object}>, nil] enclosure attribute hashes
|
|
30
37
|
# @param categories [Array<String>, nil] category labels
|
|
31
38
|
# @param scraper [Class, nil] scraper class that produced the article
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
# @param media [Boolean, nil] whether the item is an audio/video object (not an article)
|
|
40
|
+
# @param compose_title [Boolean] opt in to kicker joining and noise stripping; default keeps the title verbatim
|
|
41
|
+
# rubocop:disable-next Metrics/ParameterLists -- thirteen article inputs stay explicit
|
|
42
|
+
def initialize(id: nil, title: nil, kicker: nil, description: nil, url: nil, image: nil, author: nil, guid: nil,
|
|
43
|
+
published_at: nil, enclosures: nil, categories: nil, scraper: nil, media: nil, compose_title: false)
|
|
44
|
+
title = compose_title ? Title.compose(title, kicker:) : Title.collapse(title)
|
|
45
|
+
image = first_image(enclosures) unless present?(image)
|
|
46
|
+
@to_h = { id:, title:, description:, url:, image:, author:, guid:, published_at:,
|
|
47
|
+
enclosures:, categories:, scraper:, media: }.compact.transform_values { freeze_option(_1) }.freeze
|
|
37
48
|
|
|
38
49
|
@url = @image = @guid = @enclosures = @categories = @published_at = NOT_SET
|
|
39
50
|
end
|
|
40
51
|
|
|
52
|
+
# Sole owner of the "an item needs a URL" rule: auto-source ({#valid?}) and selector items
|
|
53
|
+
# ({Html2rss::Selectors#each}) both admit only linked articles.
|
|
54
|
+
# @return [Boolean] true when the article carries a URL
|
|
55
|
+
def linked? = !url.to_s.empty?
|
|
56
|
+
|
|
41
57
|
# Checks if the article is valid based on the presence of URL, ID, and either title or description.
|
|
42
58
|
# @return [Boolean] True if the article is valid, otherwise false.
|
|
43
59
|
def valid?
|
|
44
|
-
|
|
60
|
+
linked? && (!title.to_s.empty? || !description.to_s.empty?) && !id.to_s.empty?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param new_url [String, Html2rss::Url] replacement article URL
|
|
64
|
+
# @return [Article] a copy of this article carrying +new_url+
|
|
65
|
+
def with_url(new_url) = self.class.new(**@to_h, url: new_url)
|
|
66
|
+
|
|
67
|
+
# Folds a later copy of the same article into this one: every field this copy carries wins
|
|
68
|
+
# (title, date, guid, description, scraper), and the other copy fills only the fields it lacks.
|
|
69
|
+
#
|
|
70
|
+
# @param other [Article] another copy of this article (same {Url#article_identity})
|
|
71
|
+
# @return [Article] self when +other+ adds nothing, else a copy carrying the filled fields
|
|
72
|
+
def merge(other)
|
|
73
|
+
filled = other.provided.reject { |key, _| present?(@to_h[key]) }
|
|
74
|
+
filled.empty? ? self : self.class.new(**@to_h, **filled)
|
|
45
75
|
end
|
|
46
76
|
|
|
47
77
|
# @return [String, nil] stable article identifier
|
|
48
78
|
def id = blank_string_to_nil(@to_h[:id])
|
|
49
79
|
|
|
50
|
-
# @return [
|
|
51
|
-
def
|
|
80
|
+
# @return [Boolean] whether the scraper marked this item as an audio/video object
|
|
81
|
+
def media? = @to_h[:media] == true
|
|
82
|
+
|
|
83
|
+
# @return [String, nil] whitespace-collapsed title; +Kicker: Headline+ without noise when composed
|
|
84
|
+
def title = @to_h[:title]
|
|
52
85
|
|
|
53
86
|
# Raw extracted description — feed HTML enrichment is {FeedBuilder::ItemPresentation.description_for}.
|
|
54
87
|
#
|
|
@@ -59,14 +92,17 @@ module Html2rss
|
|
|
59
92
|
def url
|
|
60
93
|
return @url unless @url == NOT_SET
|
|
61
94
|
|
|
62
|
-
@url = Url.sanitize(@to_h[:url])
|
|
95
|
+
@url = http_url(@to_h[:url]) || Url.sanitize(@to_h[:url])
|
|
63
96
|
end
|
|
64
97
|
|
|
98
|
+
# The item image a feed reader and the description's +img+ load: an http(s) URL ({Url#web?}), never an
|
|
99
|
+
# +ftp:+ or +mailto:+ one.
|
|
100
|
+
#
|
|
65
101
|
# @return [Url, nil]
|
|
66
102
|
def image
|
|
67
103
|
return @image unless @image == NOT_SET
|
|
68
104
|
|
|
69
|
-
@image = Url.sanitize(@to_h[:image])
|
|
105
|
+
@image = Url.sanitize(@to_h[:image]).then { _1 if _1&.web? }
|
|
70
106
|
end
|
|
71
107
|
|
|
72
108
|
# @return [String, nil]
|
|
@@ -88,12 +124,16 @@ module Html2rss
|
|
|
88
124
|
dedup_from_url || dedup_from_id || dedup_from_guid || hash
|
|
89
125
|
end
|
|
90
126
|
|
|
127
|
+
# The item's downloadable files ({Enclosure#file?}): a watch or player page, stream playlist or Flash player
|
|
128
|
+
# is never an enclosure, so neither feed format ships one.
|
|
129
|
+
#
|
|
91
130
|
# @return [Array<Html2rss::Article::Enclosure>] normalized enclosure objects
|
|
92
131
|
def enclosures
|
|
93
132
|
return @enclosures unless @enclosures == NOT_SET
|
|
94
133
|
|
|
95
134
|
@enclosures = Array(@to_h[:enclosures])
|
|
96
135
|
.map { |enclosure| Enclosure.new(**enclosure) }
|
|
136
|
+
.select(&:file?)
|
|
97
137
|
.freeze
|
|
98
138
|
end
|
|
99
139
|
|
|
@@ -124,8 +164,34 @@ module Html2rss
|
|
|
124
164
|
@to_h[:scraper]
|
|
125
165
|
end
|
|
126
166
|
|
|
167
|
+
protected
|
|
168
|
+
|
|
169
|
+
# @return [Hash{Symbol => Object}] the constructor payload, for {#merge}
|
|
170
|
+
def provided = @to_h
|
|
171
|
+
|
|
127
172
|
private
|
|
128
173
|
|
|
174
|
+
# The item image of an article that declares none: its first image file, which stays an enclosure.
|
|
175
|
+
def first_image(enclosures)
|
|
176
|
+
Array(enclosures).each do |enclosure|
|
|
177
|
+
file = Enclosure.new(**enclosure)
|
|
178
|
+
return file.url if file.image?
|
|
179
|
+
end
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def present?(value)
|
|
184
|
+
case value
|
|
185
|
+
when String, Array then !value.empty?
|
|
186
|
+
else !value.nil?
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# A {Url} is always normalized; http(s) ones need no re-parse. Others go through +Url.sanitize+.
|
|
191
|
+
def http_url(value)
|
|
192
|
+
value if value.is_a?(Url) && value.web?
|
|
193
|
+
end
|
|
194
|
+
|
|
129
195
|
##
|
|
130
196
|
# Marshal only the constructor payload so lazy +NOT_SET+ sentinels do not break
|
|
131
197
|
# {FeedResult} cache round-trips.
|