portage-cli 0.10.0 → 0.12.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +129 -0
  3. data/README.md +107 -21
  4. data/known-stores/categories.yml +6841 -18
  5. data/known-stores/category-stoplist.yml +40 -0
  6. data/known-stores/category-synonyms.yml +15 -0
  7. data/lib/portage/cli/browser_import/categorize.rb +5 -2
  8. data/lib/portage/cli/buy.rb +217 -39
  9. data/lib/portage/cli/check.rb +11 -1
  10. data/lib/portage/cli/classifier/ranking.rb +135 -0
  11. data/lib/portage/cli/classifier/table.rb +63 -0
  12. data/lib/portage/cli/classifier.rb +23 -44
  13. data/lib/portage/cli/confidence_check.rb +27 -7
  14. data/lib/portage/cli/confidence_state.rb +109 -0
  15. data/lib/portage/cli/doctor.rb +16 -6
  16. data/lib/portage/cli/find.rb +69 -4
  17. data/lib/portage/cli/index/builder.rb +49 -12
  18. data/lib/portage/cli/index/database.rb +150 -0
  19. data/lib/portage/cli/index/entry_product.rb +37 -0
  20. data/lib/portage/cli/index/legacy_import.rb +54 -0
  21. data/lib/portage/cli/index/product_store.rb +73 -35
  22. data/lib/portage/cli/index/schema.rb +70 -0
  23. data/lib/portage/cli/index/search.rb +73 -0
  24. data/lib/portage/cli/index/sources/storefront_products/mapper.rb +127 -0
  25. data/lib/portage/cli/index/sources/storefront_products/pages.rb +114 -0
  26. data/lib/portage/cli/index/sources/storefront_products/robots.rb +70 -0
  27. data/lib/portage/cli/index/sources/storefront_products.rb +147 -0
  28. data/lib/portage/cli/index/sources.rb +5 -2
  29. data/lib/portage/cli/index/store.rb +23 -47
  30. data/lib/portage/cli/index.rb +1 -0
  31. data/lib/portage/cli/offer_sources.rb +17 -2
  32. data/lib/portage/cli/version.rb +1 -1
  33. data/lib/portage/cli.rb +111 -12
  34. metadata +30 -2
@@ -0,0 +1,63 @@
1
+ require "yaml"
2
+
3
+ module Portage
4
+ module Cli
5
+ module Classifier
6
+ # The shipped categories.yml and the user's ~/.portage/categories.yml,
7
+ # merged (a user node replaces a shipped node of the same id), with the
8
+ # keyword => node ids indexes the scoring looks words up in.
9
+ Table = Struct.new(:nodes, :own, :parent)
10
+
11
+ class Table
12
+ @cache = {}
13
+
14
+ class << self
15
+ # Built once per pair of files and kept for the process, keyed by
16
+ # the files' mtime and size, so an edit to ~/.portage/categories.yml
17
+ # still shows up on the next call; classifying a whole catalogue
18
+ # would otherwise rebuild the index for every product.
19
+ def for(known_path, user_path)
20
+ key = [signature(known_path), signature(user_path)]
21
+ @cache.clear if @cache.size > 8
22
+ @cache[key] ||= build(known_path, user_path)
23
+ end
24
+
25
+ # @return [Hash] the YAML mapping at `path`; empty when the file is
26
+ # missing, unreadable or not a mapping.
27
+ def load_yaml(path)
28
+ return {} unless path && File.readable?(path)
29
+
30
+ data = YAML.safe_load_file(path)
31
+ data.is_a?(Hash) ? data : {}
32
+ rescue StandardError
33
+ {}
34
+ end
35
+
36
+ private
37
+
38
+ def build(known_path, user_path)
39
+ ordered = {}
40
+ load_yaml(known_path).each_with_index { |(id, node), i| ordered[id] = node.merge("order" => i) }
41
+ load_yaml(user_path).each_with_index do |(id, node), i|
42
+ ordered[id] = node.merge("order" => ordered.size + i)
43
+ end
44
+ new(ordered, index(ordered, "keywords"), index(ordered, "parent_keywords"))
45
+ end
46
+
47
+ def index(nodes, field)
48
+ result = Hash.new { |hash, key| hash[key] = [] }
49
+ nodes.each { |id, node| Array(node[field]).each { |keyword| result[keyword] << id } }
50
+ result.default_proc = nil
51
+ result
52
+ end
53
+
54
+ def signature(path)
55
+ return nil unless path && File.readable?(path)
56
+
57
+ [path, File.mtime(path).to_r, File.size(path)]
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -1,4 +1,6 @@
1
1
  require "yaml"
2
+ require_relative "classifier/table"
3
+ require_relative "classifier/ranking"
2
4
 
3
5
  module Portage
4
6
  module Cli
@@ -18,10 +20,21 @@ module Portage
18
20
  # check matches in both directions ("carpet" contains "pet", "chair"
19
21
  # contains "hair", "scarf" contains "car") and was a real source of
20
22
  # false positives before this became whole-word.
23
+ #
24
+ # `known-stores/categories.yml` is generated by `script/categories`: a level-2
25
+ # node's `keywords` are its own name plus its descendants', `parent_keywords`
26
+ # are its parent's words, and `category-stoplist.yml` words are left out.
27
+ # See Ranking for how a text is scored against it.
21
28
  module Classifier
22
29
  # `known-stores/categories.yml`, from `lib/portage/cli/classifier.rb`.
23
30
  KNOWN_PATH = File.expand_path("../../../known-stores/categories.yml", __dir__).freeze
24
31
 
32
+ # Words that are never evidence of a category (`known-stores/category-stoplist.yml`,
33
+ # `word: why`): merchandising and url words, and words that name a whole family of
34
+ # nodes. `script/categories` leaves them out of the keywords; `categories_for`
35
+ # drops them from its input, so the two sides agree.
36
+ STOPLIST_PATH = File.expand_path("../../../known-stores/category-stoplist.yml", __dir__).freeze
37
+
25
38
  # The user's own additions/overrides — same id overrides a shipped
26
39
  # node's keywords, a new id extends the taxonomy. Absent by default;
27
40
  # nothing here is required for the shipped file to work.
@@ -44,21 +57,22 @@ module Portage
44
57
  # @param known_path [String] override for KNOWN_PATH — specs redirect
45
58
  # this the same way SearchBackends::Allowlist takes its own `path:`.
46
59
  # @param user_path [String] override for PATH.
47
- # @return [Array<String>] category ids, most keyword hits first. Ties
48
- # keep the shipped file's own order. Empty when nothing matches.
49
- def self.categories_for(text, known_path: KNOWN_PATH, user_path: PATH)
50
- words = tokenize(text)
51
- return [] if words.empty?
52
-
53
- scored = nodes(known_path, user_path).filter_map { |id, node| rank(id, node, words) }
54
- scored.sort_by { |(_id, score, order)| [-score, order] }.map(&:first)
60
+ # @param stoplist_path [String] override for STOPLIST_PATH.
61
+ # @return [Array<String>] up to MAX_CATEGORIES category ids, best score
62
+ # first (ties: see .tie_breakers). Empty when nothing matches.
63
+ def self.categories_for(text, known_path: KNOWN_PATH, user_path: PATH, stoplist_path: STOPLIST_PATH)
64
+ stopped = Table.load_yaml(stoplist_path)
65
+ counts = Ranking.word_counts(tokenize(text).reject { |word| stopped.key?(word) })
66
+ return [] if counts.empty?
67
+
68
+ Ranking.best(counts, Table.for(known_path, user_path), stopped)
55
69
  end
56
70
 
57
71
  # @return [Array<String>] the taxonomy names for `ids`, in order —
58
72
  # `portage browser import` (Phase 3) shows a domain's guessed
59
73
  # categories by name so the user can judge them before saving.
60
74
  def self.names_for(ids, known_path: KNOWN_PATH, user_path: PATH)
61
- all = nodes(known_path, user_path)
75
+ all = Table.for(known_path, user_path).nodes
62
76
  Array(ids).filter_map { |id| all.dig(id.to_s, "name") }
63
77
  end
64
78
 
@@ -88,17 +102,6 @@ module Portage
88
102
  .select { |word| word.length >= MIN_WORD_LENGTH }
89
103
  end
90
104
 
91
- # --- Scoring one node against the tokenized input ---
92
-
93
- def self.rank(id, node, words)
94
- keywords = Array(node["keywords"])
95
- hits = keywords.count { |keyword| words.any? { |word| word_match?(word, keyword) } }
96
- return nil unless hits.positive?
97
-
98
- [id, hits, node["order"].to_i]
99
- end
100
- private_class_method :rank
101
-
102
105
  # Whole-word only, plus the plural forms a keyword list and a real
103
106
  # query/title actually differ by: an exact match, one plus a trailing
104
107
  # "s" or "es" ("boot"/"boots", "watch"/"watches"), or the "y"/"ies"
@@ -129,30 +132,6 @@ module Portage
129
132
  (keyword.end_with?("ies") && word == "#{keyword[0..-4]}y")
130
133
  end
131
134
  private_class_method :ies_y_match?
132
-
133
- # --- Loading and merging the two files ---
134
-
135
- # Re-read on every call rather than cached process-wide: `find` calls
136
- # this once or twice per invocation, not in a hot loop, and a cached
137
- # copy would miss an edit to ~/.portage/categories.yml until the next
138
- # process start.
139
- def self.nodes(known_path, user_path)
140
- ordered = {}
141
- load_yaml(known_path).each_with_index { |(id, node), i| ordered[id] = node.merge("order" => i) }
142
- load_yaml(user_path).each_with_index { |(id, node), i| ordered[id] = node.merge("order" => ordered.size + i) }
143
- ordered
144
- end
145
- private_class_method :nodes
146
-
147
- def self.load_yaml(path)
148
- return {} unless path && File.readable?(path)
149
-
150
- data = YAML.safe_load_file(path)
151
- data.is_a?(Hash) ? data : {}
152
- rescue StandardError
153
- {}
154
- end
155
- private_class_method :load_yaml
156
135
  end
157
136
  end
158
137
  end
@@ -8,7 +8,9 @@ module Portage
8
8
  # § Responsibilities 3) as `portage buy` uses it: one yes/no question put
9
9
  # to a Decision::ModelBackends backend right before an unattended
10
10
  # (`--yes`) completion — "is this checkout what the shopper asked for,
11
- # and safe to complete without a person looking at it?"
11
+ # and safe to complete without a person looking at it?" Also asked
12
+ # before the WebMCP preset flow sends the browser to the store's
13
+ # checkout and autofills it (Buy#webmcp_cart_held_report).
12
14
  #
13
15
  # Default off. Nothing asks a model anything unless a backend is named,
14
16
  # via `--decision-backend NAME` or PORTAGE_DECISION_BACKEND (`jev` or
@@ -22,6 +24,16 @@ module Portage
22
24
  # purchase and hands the checkout to the shopper. It never
23
25
  # auto-approves anything `--yes` wouldn't already have allowed.
24
26
  #
27
+ # Additive only. Buy runs it after #reconcile_checkout's deterministic
28
+ # mismatch check, the quote cap and the spend policy, and only on a
29
+ # checkout all three let through, so a "yes" here can never let
30
+ # through a purchase one of those would stop. A "no" can only hold it.
31
+ #
32
+ # What it sends is ConfidenceState's allowlisted summary, never the
33
+ # raw checkout: no payment token, no address, no buyer contact
34
+ # details. #call JSON-encodes whatever state it is given, so a caller
35
+ # outside Buy has to build its state the same way.
36
+ #
25
37
  # Fails closed. An unknown backend name, a backend that isn't configured
26
38
  # (no JEV_API_KEY, no Laya bridge), or a backend call that fails all hold
27
39
  # the purchase, the same as a low score does. So does naming a backend
@@ -35,11 +47,18 @@ module Portage
35
47
  QUESTION = "safe_to_complete".freeze
36
48
  # Phrased so "yes" means "proceed": a noul answer's value is the
37
49
  # probability of yes, and that is what ConfidenceGate thresholds.
38
- INSTRUCTIONS = "The state is a checkout an agent built on a shopper's behalf: the shopper's search " \
39
- "query, the merchant, the requested quantity, the checkout's line items and totals, and " \
40
- "any warnings about where the checkout differs from the request. Answer yes only if the " \
41
- "checkout clearly matches what the shopper asked for and is safe to complete without a " \
42
- "person reviewing it first.".freeze
50
+ INSTRUCTIONS = "The state is a summary of a checkout an agent built on a shopper's behalf. `request` " \
51
+ "is what the shopper asked for: the search query, the store, the quantity, and the item " \
52
+ "picked from the store's search results. `approved_quote`, when present, is the exact " \
53
+ "purchase the shopper approved beforehand: store, product, title, quantity, total and " \
54
+ "currency. `checkout` is what the store is about to charge for: its line items (`requested` " \
55
+ "marks the line that was asked for), totals including any shipping, tax and fees, " \
56
+ "discounts and the selected shipping option. `warnings` lists differences an automatic " \
57
+ "check already found. Answer yes only if the checkout clearly matches the request and, " \
58
+ "when there is one, the approved quote: the same product (not a different model, size, " \
59
+ "bundle or subscription), the same quantity, no extra items, and a total with no " \
60
+ "surprising shipping, fees or other charges. Answer no if anything is unclear, missing " \
61
+ "or doesn't fit, or if the checkout would need a person to review it before paying.".freeze
43
62
 
44
63
  # @param backend [String, nil] a ModelBackends::REGISTRY key; nil
45
64
  # defers to PORTAGE_DECISION_BACKEND.
@@ -64,7 +83,8 @@ module Portage
64
83
 
65
84
  def enabled? = !@backend_name.nil?
66
85
 
67
- # @param state [Hash] JSON-serializable. Never pass it a payment token.
86
+ # @param state [Hash] JSON-serializable — ConfidenceState.build's
87
+ # output. Never a payment token, address or contact detail.
68
88
  # @return [Hash, nil] nil when disabled. Otherwise `proceed:`,
69
89
  # `reason:`, `confidence:`, `threshold:`, `backend:` and `error:`.
70
90
  # `reason` is nil when it proceeds, else why it held:
@@ -0,0 +1,109 @@
1
+ require "portage/ucp"
2
+
3
+ module Portage
4
+ module Cli
5
+ # The state ConfidenceCheck sends to its model backend, which may be a
6
+ # hosted third-party API (Jev, run by TypeSafe). Built from an allowlist:
7
+ # every field below is copied by name, and nothing else in the checkout
8
+ # hash is ever read. A store can put a buyer block, a shipping address,
9
+ # payment handlers or anything else on its checkout, and none of it
10
+ # reaches the backend, because this never copies a sub-hash wholesale.
11
+ #
12
+ # Never sent: the payment token (or any token reference), the shipping
13
+ # address and destinations, the buyer's name, phone or email, links and
14
+ # continue URLs, ids of the checkout itself, image URLs, discount codes,
15
+ # and anything read from the environment.
16
+ #
17
+ # Sent:
18
+ # - `request`: the search query, the store's host, the requested
19
+ # quantity, and the item id and title that were picked from the
20
+ # store's own search results.
21
+ # - `approved_quote` (only on `buy --quote`): the store, product id,
22
+ # title, quantity, total and currency the person approved.
23
+ # - `checkout`: its status and currency; each line's item id, title,
24
+ # unit price, quantity, line totals and whether it is the requested
25
+ # line; the checkout's totals (subtotal, shipping, tax, fees, total:
26
+ # whatever types the store sends); applied discounts' titles and
27
+ # amounts; and the title and price of each selected shipping option.
28
+ # - `warnings`: the deterministic check's warnings, if any.
29
+ #
30
+ # Strings are cut to MAX_STRING characters and anything that isn't a
31
+ # string, number, boolean or nil is dropped, so a store can't smuggle a
32
+ # nested object (or a very long prompt) in through a title.
33
+ module ConfidenceState
34
+ MAX_STRING = 200
35
+
36
+ module_function
37
+
38
+ # @param request [Hash] `query:`, `merchant:`, `quantity:`,
39
+ # `item_id:`, `item_title:`.
40
+ # @param checkout [Hash] a checkout or cart wire hash, string-keyed.
41
+ # @param warnings [Array<String>]
42
+ # @param quote [Hash, nil] `store:`, `product_id:`, `title:`,
43
+ # `quantity:`, `total:`, `currency:` — nil when the run has no
44
+ # approved quote.
45
+ # @return [Hash] JSON-serializable, string-keyed.
46
+ def build(request:, checkout:, warnings:, quote: nil)
47
+ state = { "request" => pick(request, %i[query merchant quantity item_id item_title]) }
48
+ state["approved_quote"] = pick(quote, %i[store product_id title quantity total currency]) if quote
49
+ state["checkout"] = checkout_summary(checkout, request[:item_id])
50
+ state["warnings"] = Array(warnings).map { |warning| scalar(warning.to_s) }
51
+ state
52
+ end
53
+
54
+ def checkout_summary(checkout, requested_id)
55
+ { "status" => scalar(checkout["status"]), "currency" => scalar(checkout["currency"]),
56
+ "line_items" => Array(checkout["line_items"]).map { |line| line_summary(line, requested_id) },
57
+ "totals" => totals_summary(checkout["totals"]),
58
+ "discounts" => discounts_summary(checkout["discounts"]),
59
+ "shipping" => shipping_summary(checkout["fulfillment"]) }
60
+ end
61
+
62
+ def line_summary(line, requested_id)
63
+ line = {} unless line.is_a?(Hash)
64
+ item = line["item"].is_a?(Hash) ? line["item"] : {}
65
+ { "item_id" => scalar(item["id"]), "title" => scalar(item["title"]), "unit_price" => scalar(item["price"]),
66
+ "quantity" => scalar(line["quantity"]), "totals" => totals_summary(line["totals"]),
67
+ "requested" => !requested_id.nil? && item["id"] == requested_id }
68
+ end
69
+
70
+ def totals_summary(totals)
71
+ hashes(totals).map { |total| { "type" => scalar(total["type"]), "amount" => scalar(total["amount"]) } }
72
+ end
73
+
74
+ def discounts_summary(discounts)
75
+ applied = discounts.is_a?(Hash) ? discounts["applied"] : nil
76
+ hashes(applied).map do |discount|
77
+ { "title" => scalar(discount["title"]), "amount" => scalar(discount["amount"]) }
78
+ end
79
+ end
80
+
81
+ # Only the options the checkout has selected — the price the person
82
+ # pays for shipping — never the destinations they're priced for.
83
+ def shipping_summary(fulfillment)
84
+ methods = fulfillment.is_a?(Hash) ? fulfillment["methods"] : nil
85
+ hashes(methods).flat_map { |method| hashes(method["groups"]) }.filter_map do |group|
86
+ option = hashes(group["options"]).find { |o| o["id"] == group["selected_option_id"] }
87
+ next unless option
88
+
89
+ { "title" => scalar(option["title"]),
90
+ "amount" => scalar(Portage::Ucp::Support::Totals.amount(hashes(option["totals"]))) }
91
+ end
92
+ end
93
+
94
+ def pick(hash, keys)
95
+ keys.to_h { |key| [key.to_s, scalar(hash[key])] }
96
+ end
97
+
98
+ def hashes(value) = Array(value).grep(Hash)
99
+
100
+ def scalar(value)
101
+ case value
102
+ when String then value[0, MAX_STRING]
103
+ when Integer, true, false, nil then value
104
+ when Float then value.finite? ? value : nil
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -42,7 +42,8 @@ module Portage
42
42
  # those four warnings were noise on every fresh install.
43
43
  def initialize(adapter_class: nil, proxy_settings: ProxySettings.new, install_doctor: InstallDoctor.new,
44
44
  seller: true, dot_env_path: DotEnv.loaded_path, index_stores: Index::Store.new,
45
- index_products: Index::ProductStore.new, known_cache: Index::KnownCache.new)
45
+ index_products: Index::ProductStore.new, known_cache: Index::KnownCache.new,
46
+ index_database: Index::Database.new(path: Index::Database.path_for(Index::Store::PATH)))
46
47
  @adapter_class = adapter_class
47
48
  @proxy_settings = proxy_settings
48
49
  @install_doctor = install_doctor
@@ -51,6 +52,7 @@ module Portage
51
52
  @index_stores = index_stores
52
53
  @index_products = index_products
53
54
  @known_cache = known_cache
55
+ @index_database = index_database
54
56
  end
55
57
 
56
58
  # `portage setup` always offers its wizard on a TTY; a bare `portage
@@ -210,12 +212,20 @@ module Portage
210
212
  # leaves behind.
211
213
  def index_finding
212
214
  refresh_known_cache_if_stale
213
- return Finding.new(check: "index", level: "info", message: "#{index_message}\n#{known_cache_message}") \
214
- if @index_stores.exists?
215
+ first = @index_stores.exists? ? index_message : no_index_message
216
+ database = @index_database.info
217
+ Finding.new(check: "index", level: "info", details: { database: database },
218
+ message: [first, database_message(database), known_cache_message].join("\n"))
219
+ end
220
+
221
+ def no_index_message
222
+ "No local index yet — run `portage index build` to give `find` a list of " \
223
+ "stores/products on top of stores.yml and web search."
224
+ end
215
225
 
216
- Finding.new(check: "index", level: "info",
217
- message: "No local index yet — run `portage index build` to give `find` a list of " \
218
- "stores/products on top of stores.yml and web search.\n#{known_cache_message}")
226
+ def database_message(info)
227
+ "Index database: #{info[:path]} (#{info[:stores]} store row(s), #{info[:products]} product row(s), " \
228
+ "FTS5 #{info[:fts5] ? 'available' : 'not available'})."
219
229
  end
220
230
 
221
231
  def refresh_known_cache_if_stale
@@ -24,6 +24,12 @@ module Portage
24
24
  # search ranker and the purchase decision to `--yes` in one breath is how
25
25
  # you end up owning a counterfeit from a shop you've never heard of, so
26
26
  # picking a store stays an explicit act (see Cli.run_buy's `--store` gate).
27
+ #
28
+ # `store:` narrows the same pipeline to one store the caller already
29
+ # named — the live re-check of an index hit or an earlier offer. No
30
+ # search backend or OfferSource runs; the store is probed and its catalog
31
+ # searched, read-only, so its offers carry the same shape, `offer_ref`
32
+ # and history entry as any other find.
27
33
  class Find
28
34
  CART_CAP = "dev.ucp.shopping.cart".freeze
29
35
  CHECKOUT_CAP = "dev.ucp.shopping.checkout".freeze
@@ -43,8 +49,11 @@ module Portage
43
49
  # candidate on it is never probed (see #call): it still surfaces as
44
50
  # a candidate, marked `handoff_only: true`, so an agent can list it
45
51
  # ("Amazon also sells this") without this process ever fetching it.
52
+ # @param store [String, nil] a store URL (see Find.store_origin): search
53
+ # only that store, live, and skip every backend and OfferSource.
46
54
  def initialize(query:, limit: MAX_PROBES, max_price: nil, backends: nil, cache: nil, throttle: THROTTLE,
47
- offer_sources: nil, handoff_only: nil)
55
+ offer_sources: nil, handoff_only: nil, store: nil)
56
+ @store = store
48
57
  @query = query.to_s
49
58
  @limit = [limit, MAX_PROBES].min
50
59
  @max_price = max_price
@@ -55,6 +64,27 @@ module Portage
55
64
  @handoff_only = handoff_only || HandoffOnly.new
56
65
  end
57
66
 
67
+ # The origin a `--store` value names: an http(s) URL (or a bare host,
68
+ # as `check` and `buy` accept) collapsed onto scheme://host[:port].
69
+ # @return [String, nil] nil for anything else, such as ftp:// or junk.
70
+ def self.store_origin(url)
71
+ text = url.to_s.strip
72
+ return nil if text.empty? || (text.include?("://") && !text.match?(%r{\Ahttps?://}i))
73
+
74
+ uri = URI.parse(text.include?("://") ? text : "https://#{text}")
75
+ origin_of_uri(uri)
76
+ rescue URI::InvalidURIError
77
+ nil
78
+ end
79
+
80
+ def self.origin_of_uri(uri)
81
+ return nil unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
82
+
83
+ port = uri.port == uri.default_port ? "" : ":#{uri.port}"
84
+ "#{uri.scheme}://#{uri.host.downcase}#{port}"
85
+ end
86
+ private_class_method :origin_of_uri
87
+
58
88
  def call
59
89
  return report(message: "Nothing to search for — pass --query.") if @query.strip.empty?
60
90
 
@@ -88,6 +118,8 @@ module Portage
88
118
  # --- Step 1: ask the backends who might sell this ---
89
119
 
90
120
  def candidate_origins
121
+ return store_candidate if @store
122
+
91
123
  seen = {}
92
124
  @backends.each do |backend|
93
125
  urls_from(backend).each { |url| add_candidate(seen, backend, url) }
@@ -95,6 +127,15 @@ module Portage
95
127
  seen.values.first(@limit)
96
128
  end
97
129
 
130
+ # `--store`: the one candidate is the named store itself. A hand-off-only
131
+ # host stays a candidate, flagged, so #probe_candidates never fetches it.
132
+ def store_candidate
133
+ origin = self.class.store_origin(@store)
134
+ return [] unless origin
135
+
136
+ [{ origin: origin, source: "store", handoff_only: @handoff_only.host?(URI.parse(origin).host) }]
137
+ end
138
+
98
139
  # Keyed by host rather than by full origin: backends routinely hand back
99
140
  # both `http://` and `https://` for the same shop, and probing one host
100
141
  # twice over two schemes is a wasted request every time. https wins when
@@ -141,6 +182,8 @@ module Portage
141
182
  # #urls_from gives the URL backends. --max-price applies here exactly
142
183
  # as it does to a probed store's offers in #offer.
143
184
  def source_offers
185
+ return [] if @store
186
+
144
187
  offers = @offer_sources.flat_map do |source|
145
188
  source.offers(@query, limit: PER_STORE_RESULTS, context: BuyerContext.from_env)
146
189
  end
@@ -229,9 +272,11 @@ module Portage
229
272
  amount, currency = price_of(product)
230
273
  return nil if over_max_price?(amount)
231
274
 
232
- { store: store[:origin], source: store[:source], checkout: store[:checkout],
233
- product_id: field(product, "id"), title: field(product, "title"),
234
- amount: amount, currency: currency, url: field(product, "url") }
275
+ OfferSources.with_product(
276
+ { store: store[:origin], source: store[:source], checkout: store[:checkout],
277
+ product_id: field(product, "id"), title: field(product, "title"),
278
+ amount: amount, currency: currency, url: field(product, "url") }, product
279
+ )
235
280
  end
236
281
 
237
282
  # An unpriced offer stays in: no price isn't the same as too dear.
@@ -279,6 +324,8 @@ module Portage
279
324
  end
280
325
 
281
326
  def no_candidates_message
327
+ return "#{@store.inspect} isn't an http(s) store URL." if @store
328
+
282
329
  names = @backends.map(&:name)
283
330
  return no_backends_message if names.empty?
284
331
 
@@ -310,6 +357,8 @@ module Portage
310
357
  end
311
358
 
312
359
  def summary(candidates, stores, offers)
360
+ return store_summary(candidates.first, stores, offers) if @store
361
+
313
362
  # Counted from the offers, not `stores`: an OfferSource's offers
314
363
  # come from stores that were never probed.
315
364
  selling = offers.map { |o| o[:store] }.uniq.length
@@ -318,6 +367,22 @@ module Portage
318
367
 
319
368
  "Checked #{candidates.length} store(s); none of them speak UCP."
320
369
  end
370
+
371
+ # The `--store` wording: say what happened to that one store, not "N
372
+ # stores".
373
+ def store_summary(candidate, stores, offers)
374
+ origin = candidate[:origin]
375
+ return "Found #{offers.length} offer(s) at #{origin}." if offers.any?
376
+ return handoff_only_message(origin) if candidate[:handoff_only]
377
+ return "#{origin} doesn't speak UCP, so there is no catalogue to search." if stores.empty?
378
+
379
+ "#{origin} speaks UCP but has nothing matching \"#{@query}\"."
380
+ end
381
+
382
+ def handoff_only_message(origin)
383
+ "#{origin} is a hand-off-only retailer: Portage never fetches it, so it wasn't searched. " \
384
+ "Open the site yourself."
385
+ end
321
386
  end
322
387
  end
323
388
  end
@@ -37,6 +37,9 @@ module Portage
37
37
  THROTTLE = 0.1
38
38
  STALE_AFTER = 7 * 24 * 60 * 60
39
39
  TOP_CATEGORIES = 5
40
+ # Product sightings per ProductStore#upsert_many transaction — one
41
+ # products.json page's worth (docs/plans/local-catalogue.md Phase 2).
42
+ WRITE_BATCH = 250
40
43
 
41
44
  # Public so BrowserImport::Importer (Phase 3) labels a probed
42
45
  # origin's capabilities exactly the way an index build does.
@@ -94,13 +97,19 @@ module Portage
94
97
  # HandoffOnly) is recorded without ever probing it — the user
95
98
  # explicitly named it, but that's still not a request this process
96
99
  # sends.
97
- def add(url)
100
+ #
101
+ # `crawl: true` (`index add URL --crawl`) then reads the store's own
102
+ # catalogue through Sources::StorefrontProducts into the index.
103
+ # Opt-in: a crawl is up to 21 more requests and 20s of pauses, where
104
+ # a plain add is one probe.
105
+ def add(url, crawl: false)
98
106
  origin = origin_of(url)
99
107
  return { added: false, message: "Not a valid http(s) URL: #{url}" } unless origin
100
108
  return store_manual_handoff_only(origin) if handoff_only_origin?(origin)
101
109
 
102
110
  session = probe(origin)
103
- store_manual(origin, session)
111
+ result = store_manual(origin, session)
112
+ crawl ? crawl_added(origin, result) : result
104
113
  end
105
114
 
106
115
  # `portage index remove HOST`
@@ -184,10 +193,29 @@ module Portage
184
193
  { added: true, origin: origin, message: "Added #{origin} — hand-off only, never probed." }
185
194
  end
186
195
 
196
+ # `store_fields:` on a sighting (StorefrontProducts' crawl note and
197
+ # platform) lands on the store row as-is.
187
198
  def update_existing(origin, group)
188
199
  existing = @stores.find(origin)
200
+ fields = group.filter_map { |g| g[:store_fields] }.reduce({}, :merge)
189
201
  @stores.upsert(origin, sources: merged_sources(existing, group),
190
- categories: merge_categories(existing["categories"], group))
202
+ categories: merge_categories(existing["categories"], group), **fields)
203
+ end
204
+
205
+ def crawl_added(origin, result)
206
+ sightings = Sources::StorefrontProducts.new(stores: @stores, handoff_only: @handoff_only)
207
+ .crawl(origin, platform: @stores.find(origin)&.dig("platform"))
208
+ tagged = sightings.map { |s| s.merge(source: "storefront_products") }
209
+ apply(tagged, dry_run: false)
210
+ note = tagged.last.dig(:store_fields, :crawl)
211
+ result.merge(crawl: note, message: "#{result[:message]} #{crawl_message(note)}")
212
+ end
213
+
214
+ def crawl_message(note)
215
+ return "Catalogue not crawled (#{note['reason']})." if note["status"] == "skipped"
216
+
217
+ "Crawled #{note['products']} product(s) from #{note['pages']} page(s)" \
218
+ "#{" (stopped: #{note['reason']})" if note['reason']}."
191
219
  end
192
220
 
193
221
  def store_new(origin, session, group)
@@ -220,7 +248,7 @@ module Portage
220
248
  group.each do |sighting|
221
249
  next unless sighting[:title]
222
250
 
223
- Classifier.categories_for(sighting[:title]).each { |id| tally[id] += 1 }
251
+ categories_of(sighting).each { |id| tally[id] += 1 }
224
252
  end
225
253
  tally.sort_by { |_id, weight| -weight }.first(TOP_CATEGORIES).to_h
226
254
  end
@@ -232,17 +260,26 @@ module Portage
232
260
  def capabilities_of(session) = self.class.capabilities_of(session)
233
261
 
234
262
  def store_products(sightings)
235
- eligible = sightings.select { |s| s[:title] && @stores.find(s[:origin]) }
236
- eligible.each { |sighting| store_product(sighting) }
263
+ known = Hash.new { |memo, origin| memo[origin] = !@stores.find(origin).nil? }
264
+ eligible = sightings.select { |s| s[:title] && known[s[:origin]] }
265
+ eligible.each_slice(WRITE_BATCH) { |batch| @products.upsert_many(batch.map { |s| product_row(s) }) }
237
266
  eligible.length
238
267
  end
239
268
 
240
- def store_product(sighting)
241
- key = product_key(sighting)
242
- category = Classifier.categories_for(sighting[:title]).first
243
- @products.upsert(key, origin: sighting[:origin], seen_at: @now.to_i, title: sighting[:title],
244
- brand: sighting[:brand], gtin: sighting[:gtin], category: category,
245
- sources: [sighting[:source]].compact)
269
+ # `product:` on a sighting (StorefrontProducts' handle, url,
270
+ # image_url, options, variant_ids) is stored alongside the usual
271
+ # fields.
272
+ def product_row(sighting)
273
+ { key: product_key(sighting), origin: sighting[:origin], seen_at: @now.to_i, title: sighting[:title],
274
+ brand: sighting[:brand], gtin: sighting[:gtin], category: categories_of(sighting).first,
275
+ sources: [sighting[:source]].compact, **sighting.fetch(:product, {}) }
276
+ end
277
+
278
+ # A source that already classified its sighting (StorefrontProducts,
279
+ # on product_type and tags) says so in `categories:`; otherwise the
280
+ # title is classified here.
281
+ def categories_of(sighting)
282
+ sighting[:categories] || Classifier.categories_for(sighting[:title])
246
283
  end
247
284
 
248
285
  # GTIN when a source has one (none do yet); otherwise a normalized