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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +129 -0
- data/README.md +107 -21
- data/known-stores/categories.yml +6841 -18
- data/known-stores/category-stoplist.yml +40 -0
- data/known-stores/category-synonyms.yml +15 -0
- data/lib/portage/cli/browser_import/categorize.rb +5 -2
- data/lib/portage/cli/buy.rb +217 -39
- data/lib/portage/cli/check.rb +11 -1
- data/lib/portage/cli/classifier/ranking.rb +135 -0
- data/lib/portage/cli/classifier/table.rb +63 -0
- data/lib/portage/cli/classifier.rb +23 -44
- data/lib/portage/cli/confidence_check.rb +27 -7
- data/lib/portage/cli/confidence_state.rb +109 -0
- data/lib/portage/cli/doctor.rb +16 -6
- data/lib/portage/cli/find.rb +69 -4
- data/lib/portage/cli/index/builder.rb +49 -12
- data/lib/portage/cli/index/database.rb +150 -0
- data/lib/portage/cli/index/entry_product.rb +37 -0
- data/lib/portage/cli/index/legacy_import.rb +54 -0
- data/lib/portage/cli/index/product_store.rb +73 -35
- data/lib/portage/cli/index/schema.rb +70 -0
- data/lib/portage/cli/index/search.rb +73 -0
- data/lib/portage/cli/index/sources/storefront_products/mapper.rb +127 -0
- data/lib/portage/cli/index/sources/storefront_products/pages.rb +114 -0
- data/lib/portage/cli/index/sources/storefront_products/robots.rb +70 -0
- data/lib/portage/cli/index/sources/storefront_products.rb +147 -0
- data/lib/portage/cli/index/sources.rb +5 -2
- data/lib/portage/cli/index/store.rb +23 -47
- data/lib/portage/cli/index.rb +1 -0
- data/lib/portage/cli/offer_sources.rb +17 -2
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +111 -12
- 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
|
-
# @
|
|
48
|
-
#
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 =
|
|
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
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
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
|
|
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
|
data/lib/portage/cli/doctor.rb
CHANGED
|
@@ -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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
data/lib/portage/cli/find.rb
CHANGED
|
@@ -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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
eligible.
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|