html2rss 0.30.0 → 0.31.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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/html2rss/article/deduplicator.rb +2 -4
  4. data/lib/html2rss/article.rb +16 -39
  5. data/lib/html2rss/auto_source/README.md +1 -1
  6. data/lib/html2rss/auto_source/cleanup.rb +15 -6
  7. data/lib/html2rss/auto_source/scraper/html.rb +5 -0
  8. data/lib/html2rss/auto_source/scraper/json_state.rb +5 -0
  9. data/lib/html2rss/auto_source/scraper/meta_oembed.rb +8 -1
  10. data/lib/html2rss/auto_source/scraper/microdata.rb +8 -0
  11. data/lib/html2rss/auto_source/scraper/microformats2.rb +8 -0
  12. data/lib/html2rss/auto_source/scraper/native_feed.rb +3 -0
  13. data/lib/html2rss/auto_source/scraper/schema/thing.rb +4 -5
  14. data/lib/html2rss/auto_source/scraper/schema.rb +8 -0
  15. data/lib/html2rss/auto_source/scraper/semantic_html.rb +5 -0
  16. data/lib/html2rss/auto_source/scraper/sitemap.rb +3 -0
  17. data/lib/html2rss/auto_source/scraper/wordpress_api.rb +3 -0
  18. data/lib/html2rss/auto_source/scraper.rb +4 -14
  19. data/lib/html2rss/auto_source/segment.rb +7 -4
  20. data/lib/html2rss/auto_source/segmenter/cluster.rb +22 -23
  21. data/lib/html2rss/auto_source/segmenter/list.rb +54 -31
  22. data/lib/html2rss/auto_source/segmenter.rb +5 -5
  23. data/lib/html2rss/auto_source.rb +2 -2
  24. data/lib/html2rss/batch.rb +3 -1
  25. data/lib/html2rss/capture/README.md +6 -4
  26. data/lib/html2rss/capture.rb +67 -35
  27. data/lib/html2rss/channel.rb +3 -3
  28. data/lib/html2rss/cli/probe_view.rb +5 -3
  29. data/lib/html2rss/cli/render.rb +6 -5
  30. data/lib/html2rss/cli/validate.rb +28 -27
  31. data/lib/html2rss/cli.rb +1 -1
  32. data/lib/html2rss/config/issue_mapper.rb +108 -0
  33. data/lib/html2rss/config/request_headers.rb +2 -3
  34. data/lib/html2rss/config/schema.rb +16 -1
  35. data/lib/html2rss/config/selectors_validator.rb +92 -70
  36. data/lib/html2rss/config/validation_issue.rb +60 -0
  37. data/lib/html2rss/config/validation_report.rb +55 -0
  38. data/lib/html2rss/config/validator.rb +10 -6
  39. data/lib/html2rss/config.rb +35 -113
  40. data/lib/html2rss/defaults.rb +42 -30
  41. data/lib/html2rss/doctor/botasaurus.rb +6 -4
  42. data/lib/html2rss/doctor.rb +8 -0
  43. data/lib/html2rss/error.rb +1 -1
  44. data/lib/html2rss/feed_builder/rss.rb +9 -14
  45. data/lib/html2rss/feed_pipeline/runtime_policy.rb +14 -10
  46. data/lib/html2rss/feed_pipeline/strategy_plan.rb +6 -2
  47. data/lib/html2rss/feed_resolution/candidate_generator.rb +1 -4
  48. data/lib/html2rss/feed_resolution/diag.rb +3 -1
  49. data/lib/html2rss/feed_resolution/options.rb +3 -1
  50. data/lib/html2rss/feed_resolution/policy.rb +2 -2
  51. data/lib/html2rss/html/article_extractor/date_extractor.rb +1 -1
  52. data/lib/html2rss/html/article_extractor.rb +2 -4
  53. data/lib/html2rss/html/feed_link.rb +3 -1
  54. data/lib/html2rss/html/navigator/text_extractor.rb +1 -1
  55. data/lib/html2rss/html/navigator.rb +10 -10
  56. data/lib/html2rss/html/rendering/description_builder.rb +1 -1
  57. data/lib/html2rss/html/sst_article_extractor.rb +8 -5
  58. data/lib/html2rss/link_destination/destination_facts.rb +4 -2
  59. data/lib/html2rss/link_destination/noise_policy.rb +10 -1
  60. data/lib/html2rss/link_destination/path_classifier.rb +45 -38
  61. data/lib/html2rss/mcp/README.md +2 -0
  62. data/lib/html2rss/mcp/config_argument.rb +3 -1
  63. data/lib/html2rss/mcp/contract.rb +6 -20
  64. data/lib/html2rss/mcp/outcome/playbook.rb +2 -2
  65. data/lib/html2rss/mcp/outcome.rb +36 -43
  66. data/lib/html2rss/mcp/server/tools.rb +111 -131
  67. data/lib/html2rss/mcp/server.rb +26 -18
  68. data/lib/html2rss/page_recon/README.md +5 -5
  69. data/lib/html2rss/page_recon/diagnostics.rb +71 -81
  70. data/lib/html2rss/page_recon.rb +9 -5
  71. data/lib/html2rss/recon.rb +19 -30
  72. data/lib/html2rss/request_service/botasaurus_contract.rb +11 -12
  73. data/lib/html2rss/request_service/botasaurus_strategy.rb +1 -1
  74. data/lib/html2rss/request_service/compressed_body.rb +4 -1
  75. data/lib/html2rss/request_service/httpx_strategy.rb +39 -28
  76. data/lib/html2rss/request_service/network_guard.rb +7 -5
  77. data/lib/html2rss/request_service/response.rb +1 -2
  78. data/lib/html2rss/request_service/strategy.rb +3 -8
  79. data/lib/html2rss/scoring/container_assessor.rb +3 -1
  80. data/lib/html2rss/scoring/engine.rb +8 -8
  81. data/lib/html2rss/scoring/observation.rb +5 -3
  82. data/lib/html2rss/scoring/ranked_segment.rb +3 -1
  83. data/lib/html2rss/scrape_target.rb +3 -1
  84. data/lib/html2rss/selector_candidates.rb +234 -0
  85. data/lib/html2rss/selectors/README.md +64 -0
  86. data/lib/html2rss/selectors/extractors/attribute.rb +13 -15
  87. data/lib/html2rss/selectors/extractors/href.rb +15 -16
  88. data/lib/html2rss/selectors/extractors/html.rb +8 -9
  89. data/lib/html2rss/selectors/extractors/static.rb +12 -14
  90. data/lib/html2rss/selectors/extractors/text.rb +8 -9
  91. data/lib/html2rss/selectors/extractors.rb +33 -20
  92. data/lib/html2rss/selectors/item_env.rb +31 -0
  93. data/lib/html2rss/selectors/{object_to_xml_converter.rb → json_xml.rb} +11 -12
  94. data/lib/html2rss/selectors/option_spec.rb +140 -0
  95. data/lib/html2rss/selectors/post_processors/base.rb +79 -31
  96. data/lib/html2rss/selectors/post_processors/gsub.rb +103 -39
  97. data/lib/html2rss/selectors/post_processors/html_transformers/transform_urls_to_absolute_ones.rb +4 -4
  98. data/lib/html2rss/selectors/post_processors/markdown_to_html.rb +10 -11
  99. data/lib/html2rss/selectors/post_processors/parse_time.rb +50 -26
  100. data/lib/html2rss/selectors/post_processors/parse_uri.rb +11 -7
  101. data/lib/html2rss/selectors/post_processors/sanitize_html.rb +46 -32
  102. data/lib/html2rss/selectors/post_processors/substring.rb +14 -25
  103. data/lib/html2rss/selectors/post_processors/template.rb +23 -19
  104. data/lib/html2rss/selectors/post_processors.rb +6 -5
  105. data/lib/html2rss/selectors/{schema_doc.rb → schema_export.rb} +12 -31
  106. data/lib/html2rss/selectors/step_env.rb +24 -0
  107. data/lib/html2rss/selectors.rb +231 -142
  108. data/lib/html2rss/sst/attrs.rb +5 -3
  109. data/lib/html2rss/sst/document.rb +3 -1
  110. data/lib/html2rss/sst/index.rb +3 -1
  111. data/lib/html2rss/sst/node.rb +29 -10
  112. data/lib/html2rss/sst/normalizer.rb +12 -16
  113. data/lib/html2rss/sst/tags.rb +7 -7
  114. data/lib/html2rss/status.rb +13 -8
  115. data/lib/html2rss/surface_category.rb +14 -32
  116. data/lib/html2rss/syndication/parser.rb +2 -2
  117. data/lib/html2rss/test/enhance_audit.rb +1 -3
  118. data/lib/html2rss/test/policy.rb +106 -0
  119. data/lib/html2rss/test.rb +126 -173
  120. data/lib/html2rss/url.rb +25 -4
  121. data/lib/html2rss/version.rb +1 -1
  122. data/lib/html2rss.rb +21 -13
  123. data/lib/tasks/config_schema.rake +2 -2
  124. data/schema/html2rss-config.schema.json +11 -8
  125. metadata +15 -6
  126. data/lib/html2rss/selectors/item_scope.rb +0 -30
@@ -7,47 +7,54 @@ module Html2rss
7
7
  attr_reader :segments
8
8
 
9
9
  # Soft utility segments excluded from high-confidence junk (still utility_path).
10
- SOFT_UTILITY = %w[
11
- for-you join member members membership newsletter newsletters
12
- plans plus premium pricing recommended
13
- ].to_set.freeze
10
+ SOFT_UTILITY = Set[
11
+ 'for-you', 'join', 'member', 'members', 'membership', 'newsletter', 'newsletters',
12
+ 'plans', 'plus', 'premium', 'pricing', 'recommended'
13
+ ].freeze
14
14
 
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
- content = %w[
19
- article articles blog blogs changelog changelogs insight insights
20
- launch launches news post posts release releases story stories update updates
21
- artikel beitrag beitraege nachrichten neuigkeiten aktuelles
22
- articulo articulos noticia noticias entrada entradas publicacion publicaciones
23
- actualite actualites nouvelle nouvelles teaser teasers card cards
24
- ].to_set.freeze
25
- taxonomy = %w[
26
- category categories tag tags topic topics
27
- kategorie kategorien schlagwort schlagworte thema themen
28
- categoria categorias etiqueta etiquetas tema temas
29
- categorie etiquette etiquettes sujet sujets theme themes
30
- ].to_set.freeze
31
- vanity = %w[
32
- join membership plus premium pricing plans subscribe signup
33
- abonnieren abo suscribirse boletin s-abonner saboner
34
- ].to_set.freeze
35
- chrome = %w[
36
- about account archive archives author authors comment comments contact feedback
37
- help login logout notification notifications preference preferences profile register
38
- search settings share signup subscribe feed feeds comment-feed comments-feed privacy
39
- terms cookie cookies user users autor autoren archiv ueber-uns ueber ueberuns profil
40
- kontakt impressum suche hilfe anmelden registrieren konto registrierung anmeldung
41
- abonnieren abo datenschutz nutzungsbedingungen agb autores archivos sobre-nosotros
42
- sobre quienes-somos buscar busqueda ayuda entrar ingresar registrarse registro cuenta
43
- suscribirse boletin privacidad condiciones auteur auteurs a-propos apropos recherche
44
- rechercher aide connexion s-inscrire sinscrire inscription compte s-abonner saboner
45
- lettre-information confidentialite mentions-legales cgu menu sidebar widget social
46
- modal popup banner promo ad ads related recommendation recommendations pagination pager
47
- dating jobs job career careers deals deal shopping shop trading broker versicherung
48
- tierversicherung insurance vergleich comparison partnerboerse singleboerse krypto crypto
49
- casinos casino kreditkarten kreditkarte kredit echtgeld vpn games kaufberater leasing
50
- ].to_set.freeze
18
+ content = Set[
19
+ 'article', 'articles', 'blog', 'blogs', 'changelog', 'changelogs', 'insight', 'insights',
20
+ 'launch', 'launches', 'news', 'post', 'posts', 'release', 'releases', 'story', 'stories',
21
+ 'update', 'updates', 'artikel', 'beitrag', 'beitraege', 'nachrichten', 'neuigkeiten',
22
+ 'aktuelles', 'articulo', 'articulos', 'noticia', 'noticias', 'entrada', 'entradas',
23
+ 'publicacion', 'publicaciones', 'actualite', 'actualites', 'nouvelle', 'nouvelles',
24
+ 'teaser', 'teasers', 'card', 'cards'
25
+ ].freeze
26
+ taxonomy = Set[
27
+ 'category', 'categories', 'tag', 'tags', 'topic', 'topics',
28
+ 'kategorie', 'kategorien', 'schlagwort', 'schlagworte', 'thema', 'themen',
29
+ 'categoria', 'categorias', 'etiqueta', 'etiquetas', 'tema', 'temas',
30
+ 'categorie', 'etiquette', 'etiquettes', 'sujet', 'sujets', 'theme', 'themes'
31
+ ].freeze
32
+ vanity = Set[
33
+ 'join', 'membership', 'plus', 'premium', 'pricing', 'plans', 'subscribe', 'signup',
34
+ 'abonnieren', 'abo', 'suscribirse', 'boletin', 's-abonner', 'saboner'
35
+ ].freeze
36
+ chrome = Set[
37
+ 'about', 'account', 'archive', 'archives', 'author', 'authors', 'comment', 'comments',
38
+ 'contact', 'feedback', 'help', 'login', 'logout', 'notification', 'notifications',
39
+ 'preference', 'preferences', 'profile', 'register', 'search', 'settings', 'share',
40
+ 'signup', 'subscribe', 'feed', 'feeds', 'comment-feed', 'comments-feed', 'privacy',
41
+ 'terms', 'cookie', 'cookies', 'user', 'users', 'autor', 'autoren', 'archiv', 'ueber-uns',
42
+ 'ueber', 'ueberuns', 'profil', 'kontakt', 'impressum', 'suche', 'hilfe', 'anmelden',
43
+ 'registrieren', 'konto', 'registrierung', 'anmeldung', 'abonnieren', 'abo', 'datenschutz',
44
+ 'nutzungsbedingungen', 'agb', 'autores', 'archivos', 'sobre-nosotros', 'sobre',
45
+ 'quienes-somos', 'buscar', 'busqueda', 'ayuda', 'entrar', 'ingresar', 'registrarse',
46
+ 'registro', 'cuenta', 'suscribirse', 'boletin', 'privacidad', 'condiciones', 'auteur',
47
+ 'auteurs', 'a-propos', 'apropos', 'recherche', 'rechercher', 'aide', 'connexion',
48
+ 's-inscrire', 'sinscrire', 'inscription', 'compte', 's-abonner', 'saboner',
49
+ 'lettre-information', 'confidentialite', 'mentions-legales', 'cgu', 'menu', 'sidebar',
50
+ 'widget', 'social', 'modal', 'popup', 'banner', 'promo', 'ad', 'ads', 'related',
51
+ 'recommendation', 'recommendations', 'pagination', 'pager', 'dating', 'jobs', 'job',
52
+ 'career', 'careers', 'deals', 'deal', 'shopping', 'shop', 'trading', 'broker',
53
+ 'versicherung', 'tierversicherung', 'insurance', 'vergleich', 'comparison',
54
+ 'partnerboerse', 'singleboerse', 'krypto', 'crypto', 'casinos', 'casino',
55
+ 'kreditkarten', 'kreditkarte', 'kredit', 'echtgeld', 'vpn', 'games', 'kaufberater',
56
+ 'leasing'
57
+ ].freeze
51
58
  utility = (taxonomy | chrome | SOFT_UTILITY).freeze
52
59
  {
53
60
  content:,
@@ -55,7 +62,7 @@ module Html2rss
55
62
  high_confidence_junk: (utility - SOFT_UTILITY).freeze,
56
63
  taxonomy:,
57
64
  vanity:,
58
- deep_post_context: %w[press newsroom presse pressemitteilungen prensa].to_set.freeze
65
+ deep_post_context: Set['press', 'newsroom', 'presse', 'pressemitteilungen', 'prensa'].freeze
59
66
  }.freeze
60
67
  end
61
68
  # Path segment that begins with a year-like publishing marker.
@@ -24,6 +24,8 @@ Batch: `batch_inspect`, `batch_recon`, `batch_scrape`.
24
24
  2. **Reusable feed YAML?** → `capture` → `test` → `apply`. `capture` returns YAML in `payload.yaml`. Strive `enhance: true` (false only when chrome leaks). `test` runs schema + live extraction; optional `compare_enhance` compares enhance off vs on. `apply` is the ship gate (`isError` on zero items). Both `test` and `apply` may include `quality_report.enhance_gains` when `selectors.items.enhance` is true.
25
25
  3. **Weak scrape/capture or recon?** → `inspect` (or `batch_inspect`). When alternates warrant it, follow `next_step` to `recon`.
26
26
  4. **Config already in hand?** → `validate` (schema only) → `test` → `apply`.
27
+ - `validate` failures return `payload.issues` (array of `{ path, code, message, expected, actual }`) — not a nested `errors` hash.
28
+ - `test` schema failures surface the same shape under `payload.validation_issues`.
27
29
 
28
30
  Prefer `capture` for durable configs; `scrape` / `batch_scrape` for one-shot extraction. Follow envelope `next_step` and `guidance`; do not parse scrape text as a raw item array.
29
31
 
@@ -2,10 +2,12 @@
2
2
 
3
3
  module Html2rss
4
4
  module MCP
5
+ ConfigArgument = Data.define(:config)
6
+
5
7
  # Feed config supplied over MCP as a hash XOR a YAML string.
6
8
  #
7
9
  # Config does not know MCP. YAML parsing stays on {Html2rss::Config.from_yaml}.
8
- ConfigArgument = Data.define(:config) do
10
+ class ConfigArgument
9
11
  class << self
10
12
  ##
11
13
  # @param config [Hash, nil] feed configuration hash
@@ -16,7 +16,7 @@ module Html2rss
16
16
  ALL_ACCEPTED_STRATEGIES = (STRATEGIES + MIGRATION_STRATEGIES).freeze
17
17
 
18
18
  # Bump when tool names, required inputs, or envelope semantics change (independent of gem +VERSION+).
19
- MCP_CONTRACT_VERSION = 2
19
+ MCP_CONTRACT_VERSION = 3
20
20
  public_constant :MCP_CONTRACT_VERSION
21
21
 
22
22
  # Raised when apply/validate config uses an unpublished MCP request adapter.
@@ -201,26 +201,12 @@ module Html2rss
201
201
  # Tool annotations for +validate+ (closed world).
202
202
  ANNOTATIONS_VALIDATE = ANNOTATIONS_OPEN_WORLD.merge(open_world_hint: false).freeze
203
203
 
204
- # Human titles for +tools/list+.
205
- TITLES = {
206
- scrape: 'Scrape',
207
- inspect: 'Inspect',
208
- recon: 'Recon',
209
- batch_scrape: 'Batch scrape',
210
- batch_inspect: 'Batch inspect',
211
- batch_recon: 'Batch recon',
212
- capture: 'Capture',
213
- validate: 'Validate',
214
- apply: 'Apply',
215
- test: 'Test'
216
- }.freeze
217
-
218
204
  # @api private
219
205
  CATALOG_ENTRY_LINE = lambda do |entry|
220
- schema = entry.fetch(:input_schema)
206
+ schema = entry.input_schema
221
207
  required = Array(schema[:required]).sort.join(',')
222
208
  one_of = Array(schema[:oneOf]).map { |branch| Array(branch[:required]).sort.join('+') }.sort.join('|')
223
- [entry.fetch(:name), required, one_of].reject(&:empty?).join(':')
209
+ [entry.name, required, one_of].reject(&:empty?).join(':')
224
210
  end.freeze
225
211
 
226
212
  class << self
@@ -229,7 +215,7 @@ module Html2rss
229
215
  #
230
216
  # @return [Array<String>]
231
217
  def catalog_tools
232
- Server::Tools::TOOLS.map { |entry| entry.fetch(:name) }.sort
218
+ Server::Tools::TOOLS.map(&:name).sort
233
219
  end
234
220
 
235
221
  ##
@@ -239,7 +225,7 @@ module Html2rss
239
225
  #
240
226
  # @return [String] 16-char hex digest prefix
241
227
  def catalog_fingerprint
242
- lines = Server::Tools::TOOLS.sort_by { |entry| entry.fetch(:name) }.map(&CATALOG_ENTRY_LINE)
228
+ lines = Server::Tools::TOOLS.sort_by(&:name).map(&CATALOG_ENTRY_LINE)
243
229
  Digest::SHA256.hexdigest(lines.join("\n")).slice(0, 16)
244
230
  end
245
231
 
@@ -254,7 +240,7 @@ module Html2rss
254
240
  required: %w[ok next_step guidance payload],
255
241
  properties: {
256
242
  ok: { type: 'boolean' },
257
- next_step: { type: 'string', enum: Outcome::NextStep::NAMES.map(&:to_s) },
243
+ next_step: { type: 'string', enum: Outcome::NextStep::ALL.keys.map(&:to_s) },
258
244
  guidance: { type: 'string' },
259
245
  payload: { type: 'object' }
260
246
  }
@@ -8,7 +8,7 @@ module Html2rss
8
8
  # and prompt bodies. {Server} delegates here — do not duplicate prose in
9
9
  # +server.rb+.
10
10
  class Playbook
11
- # Default guidance copy keyed by {Outcome::NextStep::NAMES}.
11
+ # Default guidance copy; {Outcome::NextStep::ALL} is built from these keys.
12
12
  GUIDANCE = {
13
13
  done: 'Done. Read payload for the result.',
14
14
  inspect: 'Call inspect next. Read payload for diagnostics (final_url, status, ' \
@@ -16,7 +16,7 @@ module Html2rss
16
16
  recon: 'Call recon next. Read payload for verdict and native_feed preference.',
17
17
  validate: 'Call validate with payload.yaml or a config hash (XOR, not both).',
18
18
  apply: 'Call apply next. Confirm payload.item_count before shipping.',
19
- scrape: 'Call scrape for articles now. strategy auto already runs Faraday then Botasaurus ' \
19
+ scrape: 'Call scrape for articles now. strategy auto already runs default (HTTPX) then Botasaurus ' \
20
20
  'and promotes native RSS/Atom when present.',
21
21
  capture: 'Call capture for a reusable YAML draft, then follow next_step.',
22
22
  read_runtime: 'Read html2rss://runtime. Compare mcp_contract_version and catalog_fingerprint ' \
@@ -13,24 +13,16 @@ module Html2rss
13
13
  NextStep = Data.define(:name, :guidance)
14
14
 
15
15
  ##
16
- # Closed set of agent next actions. Invalid names cannot be constructed.
16
+ # Closed set of agent next actions. Names are owned by {Playbook::GUIDANCE}.
17
17
  class NextStep
18
- # Wire names for +next_step+.
19
- NAMES = %i[done inspect recon validate apply scrape capture read_runtime test].freeze
18
+ # Frozen name → instance table built from {Playbook::GUIDANCE}.
19
+ ALL = Playbook::GUIDANCE.to_h { |name, guidance| [name, new(name:, guidance:)] }.freeze
20
+ private_class_method :new
20
21
 
21
22
  ##
22
23
  # @param name [Symbol, String]
23
- # @param guidance [String, nil]
24
- def initialize(name:, guidance: nil)
25
- step = name.to_sym
26
- raise ArgumentError, "unknown next_step: #{name.inspect}" unless NAMES.include?(step)
27
-
28
- super(name: step, guidance: (guidance || Playbook::GUIDANCE.fetch(step)).freeze)
29
- end
30
-
31
- class << self
32
- NAMES.each { |step| define_method(step) { new(name: step) } }
33
- end
24
+ # @return [NextStep]
25
+ def self.[](name) = ALL.fetch(name.to_sym) { raise ArgumentError, "unknown next_step: #{name.inspect}" }
34
26
  end
35
27
 
36
28
  ##
@@ -107,12 +99,13 @@ module Html2rss
107
99
  end
108
100
 
109
101
  ##
110
- # @param errors [Hash, nil] schema errors; +nil+ means success
102
+ # @param report [Html2rss::Config::ValidationReport]
111
103
  # @return [Outcome]
112
- def validate(errors:)
113
- ok = errors.nil?
114
- next_step = ok ? NextStep.test : NextStep.validate
115
- new(ok:, next_step:, guidance: next_step.guidance, payload: ok ? {} : { errors: })
104
+ def validate(report:)
105
+ ok = report.success?
106
+ next_step = ok ? NextStep[:test] : NextStep[:validate]
107
+ payload = ok ? {} : { issues: report.issues.map(&:to_h) }
108
+ new(ok:, next_step:, guidance: next_step.guidance, payload:)
116
109
  end
117
110
 
118
111
  ##
@@ -131,17 +124,17 @@ module Html2rss
131
124
  ##
132
125
  # @param batch_result [Html2rss::Batch::BatchResult]
133
126
  # @return [Outcome]
134
- def batch_scrape(batch_result) = batch(batch_result, NextStep.scrape)
127
+ def batch_scrape(batch_result) = batch(batch_result, NextStep[:scrape])
135
128
 
136
129
  ##
137
130
  # @param batch_result [Html2rss::Batch::BatchResult]
138
131
  # @return [Outcome]
139
- def batch_inspect(batch_result) = batch(batch_result, NextStep.inspect)
132
+ def batch_inspect(batch_result) = batch(batch_result, NextStep[:inspect])
140
133
 
141
134
  ##
142
135
  # @param batch_result [Html2rss::Batch::BatchResult]
143
136
  # @return [Outcome]
144
- def batch_recon(batch_result) = batch(batch_result, NextStep.recon)
137
+ def batch_recon(batch_result) = batch(batch_result, NextStep[:recon])
145
138
 
146
139
  ##
147
140
  # @param rss [String]
@@ -151,7 +144,7 @@ module Html2rss
151
144
  # @return [Outcome]
152
145
  def apply(rss:, item_count:, empty: item_count.zero?, quality_report: nil)
153
146
  ok = !empty
154
- next_step = ok ? NextStep.done : NextStep.inspect
147
+ next_step = ok ? NextStep[:done] : NextStep[:inspect]
155
148
  payload = { rss:, item_count: }
156
149
  payload[:quality_report] = quality_report if quality_report
157
150
  new(ok:, next_step:, guidance: next_step.guidance, payload:)
@@ -169,15 +162,15 @@ module Html2rss
169
162
  private
170
163
 
171
164
  def batch(batch_result, failure_step)
172
- step = batch_result.successful.positive? ? NextStep.done : failure_step
165
+ step = batch_result.successful.positive? ? NextStep[:done] : failure_step
173
166
  new(ok: true, next_step: step, guidance: step.guidance, payload: batch_result.to_h)
174
167
  end
175
168
 
176
169
  def scrape_next_step(empty, botasaurus_configured:)
177
- return NextStep.done unless empty
178
- return NextStep.read_runtime unless botasaurus_configured
170
+ return NextStep[:done] unless empty
171
+ return NextStep[:read_runtime] unless botasaurus_configured
179
172
 
180
- NextStep.inspect
173
+ NextStep[:inspect]
181
174
  end
182
175
 
183
176
  def scrape_payload(items:, requested_strategy:, channel_title:, admission_drops:)
@@ -188,33 +181,33 @@ module Html2rss
188
181
  end
189
182
 
190
183
  def inspect_next_step(report)
191
- return NextStep.recon if report.alternate_feeds?
192
- return NextStep.capture if report.articles_count.positive?
184
+ return NextStep[:recon] if report.alternate_feeds?
185
+ return NextStep[:capture] if report.articles_count.positive?
193
186
 
194
- NextStep.scrape
187
+ NextStep[:scrape]
195
188
  end
196
189
 
197
190
  def recon_next_step(result)
198
- return NextStep.done if result.defer?
199
- return NextStep.capture if result.build?
191
+ return NextStep[:done] if result.defer?
192
+ return NextStep[:capture] if result.build?
200
193
 
201
- NextStep.scrape
194
+ NextStep[:scrape]
202
195
  end
203
196
 
204
197
  def capture_next_step(articles_count:, has_selectors:, native_feed: nil)
205
- return NextStep.done if native_feed
198
+ return NextStep[:done] if native_feed
206
199
 
207
- articles_count.positive? && has_selectors ? NextStep.test : NextStep.inspect
200
+ articles_count.positive? && has_selectors ? NextStep[:test] : NextStep[:inspect]
208
201
  end
209
202
 
210
203
  def test_next_step(test_result)
211
- return NextStep.apply if test_result.success
204
+ return NextStep[:apply] if test_result.success
212
205
 
213
206
  kind = test_result.failure_kind
214
- return NextStep.validate if kind&.schema?
215
- return NextStep.capture if kind&.execution? || kind&.min_items? || kind&.quality?
207
+ return NextStep[:validate] if kind&.schema?
208
+ return NextStep[:capture] if kind&.execution? || kind&.min_items? || kind&.quality?
216
209
 
217
- NextStep.capture
210
+ NextStep[:capture]
218
211
  end
219
212
 
220
213
  def test_guidance(test_result, next_step)
@@ -244,15 +237,15 @@ module Html2rss
244
237
 
245
238
  def next_step_for_error(error)
246
239
  case error
247
- when RequestService::BotasaurusConfigurationError then NextStep.read_runtime
248
- when Contract::UnpublishedRequestError then NextStep.validate
240
+ when RequestService::BotasaurusConfigurationError then NextStep[:read_runtime]
241
+ when Contract::UnpublishedRequestError then NextStep[:validate]
249
242
  when ArgumentError then argument_error_next_step(error)
250
- else NextStep.inspect
243
+ else NextStep[:inspect]
251
244
  end
252
245
  end
253
246
 
254
247
  def argument_error_next_step(error)
255
- XOR_ERROR.match?(error.message) ? NextStep.validate : NextStep.inspect
248
+ XOR_ERROR.match?(error.message) ? NextStep[:validate] : NextStep[:inspect]
256
249
  end
257
250
  end
258
251
  end