portage-cli 0.9.0 → 0.11.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 +64 -0
- data/README.md +33 -4
- 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 +3 -24
- data/lib/portage/cli/check.rb +164 -0
- data/lib/portage/cli/check_next_step.rb +51 -0
- 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/doctor.rb +16 -6
- data/lib/portage/cli/find.rb +5 -3
- data/lib/portage/cli/handoff_host.rb +33 -0
- 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 +142 -12
- metadata +34 -4
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Words Classifier never uses as evidence of a category, on either side: script/categories
|
|
2
|
+
# leaves them out of categories.yml, and Classifier.categories_for drops them from its
|
|
3
|
+
# input. Each entry is `word: why`. Both forms of a word are listed, because the stoplist is
|
|
4
|
+
# matched exactly (the keyword match is what normalizes plurals).
|
|
5
|
+
#
|
|
6
|
+
# Two kinds of word belong here: words that describe how something is sold or filed rather
|
|
7
|
+
# than what it is, and words that label a whole family of nodes ("accessories" names 76 of
|
|
8
|
+
# the 192 level-2 nodes). A specific word that is merely ambiguous does not belong here.
|
|
9
|
+
and: function word; appears in taxonomy names ("Hardware & Tools")
|
|
10
|
+
for: function word; appears in taxonomy names ("Filters for ...")
|
|
11
|
+
the: function word
|
|
12
|
+
with: function word ("Vac with Multi-Purpose Head")
|
|
13
|
+
from: function word
|
|
14
|
+
new: merchandising flag; Light Yard tags products "New Collection"
|
|
15
|
+
sale: merchandising flag; a sale tag says nothing about the product
|
|
16
|
+
gift: merchandising flag; "Gift Set" is a way to sell, not a product type
|
|
17
|
+
gifts: merchandising flag; plural of gift
|
|
18
|
+
set: bundle word; "Gift Set" and "Furniture Set" say how it is packaged
|
|
19
|
+
sets: bundle word; plural of set
|
|
20
|
+
kit: bundle word; "drill kit" is the drill
|
|
21
|
+
kits: bundle word; plural of kit
|
|
22
|
+
collection: storefront and url word; matched "Toll Collection Devices" (4488) for every "New Collection" tag
|
|
23
|
+
collections: url and storefront word; the /collections/ path segment of every Shopify collection url
|
|
24
|
+
products: url word; the /products/ path segment of every Shopify product url
|
|
25
|
+
product: url and page-title word
|
|
26
|
+
category: url word; the /category/ path segment
|
|
27
|
+
shop: page-title word ("Cordless Drills | Hardware | Shop")
|
|
28
|
+
accessories: names 76 of the 192 level-2 nodes, so it cannot tell them apart
|
|
29
|
+
accessory: singular of accessories
|
|
30
|
+
supplies: names 25 level-2 nodes ("Pet Supplies", "Office Supplies"), so it cannot tell them apart
|
|
31
|
+
supply: singular of supplies
|
|
32
|
+
equipment: names 21 level-2 nodes, so it cannot tell them apart
|
|
33
|
+
parts: names 21 level-2 nodes ("Vehicle Parts"), so it cannot tell them apart
|
|
34
|
+
replacement: a spare, not a product type; "Replacement Fade Blade Set"
|
|
35
|
+
general: filler word ("General Office Supplies")
|
|
36
|
+
other: filler word; the catch-all name in many taxonomies
|
|
37
|
+
miscellaneous: catch-all product type; JB Hi-Fi files 237 of its first 1,250 products under "MISCELLANEOUS"
|
|
38
|
+
service: generic product-type word; JB Hi-Fi's "TELCO SERVICES" type matched Food Service (135)
|
|
39
|
+
services: plural of service; JB Hi-Fi files 279 of its first 1,250 products under "TELCO SERVICES"
|
|
40
|
+
hand: craft marker; Light Yard tags 146 of its 164 products "British Hand-Made", and "hand" also names 10 level-2 nodes ("Hand Tools")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Words the Google taxonomy has no node for, added to a node's keywords by script/categories.
|
|
2
|
+
# `id:` then `word: why`; the why names the golden case (spec/fixtures/classifier_golden.yml)
|
|
3
|
+
# that needs it, by its text. Add a word here only for a case the golden set proves, never
|
|
4
|
+
# in bulk.
|
|
5
|
+
'594':
|
|
6
|
+
pendant: pendant light
|
|
7
|
+
sconce: wall sconce
|
|
8
|
+
bollard: Bollard light Bollard Lights British Hand-Made Driveway Lights Path Lights
|
|
9
|
+
Patio Lights Wooden Lights £250-£500
|
|
10
|
+
'604':
|
|
11
|
+
whitegoods: WHITEGOODS Brand:Beko LimitedStock
|
|
12
|
+
'262':
|
|
13
|
+
telco: TELCO SERVICES Brand:Samsung InStock
|
|
14
|
+
'187':
|
|
15
|
+
boots: leather boots
|
|
@@ -21,7 +21,8 @@ module Portage
|
|
|
21
21
|
PRODUCT_PATH = %r{/products?/[^/?#]+}
|
|
22
22
|
|
|
23
23
|
# @param rows [Array<Hash>] Readers rows, all for one domain.
|
|
24
|
-
# @return [Hash{String => Integer}] category id => weight, top 5
|
|
24
|
+
# @return [Hash{String => Integer}] category id => weight, top 5 (equal
|
|
25
|
+
# weights in the order first seen).
|
|
25
26
|
def self.domain(rows)
|
|
26
27
|
texts = Hash.new(0)
|
|
27
28
|
rows.each { |row| texts[text_of(row)] += row[:visits] }
|
|
@@ -29,7 +30,9 @@ module Portage
|
|
|
29
30
|
texts.each do |text, visits|
|
|
30
31
|
Classifier.categories_for(text).each { |id| tally[id] += visits } unless text.empty?
|
|
31
32
|
end
|
|
32
|
-
|
|
33
|
+
# `sort_by` isn't stable, so the position breaks ties: first seen wins.
|
|
34
|
+
tally.each_with_index.sort_by { |(_id, weight), seen| [-weight, seen] }.first(TOP_CATEGORIES)
|
|
35
|
+
.to_h { |pair, _seen| pair }
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
# @param kept [Array<Hash>] Importer's kept entries, rows included.
|
data/lib/portage/cli/buy.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "checkout_handoff"
|
|
|
13
13
|
require_relative "money"
|
|
14
14
|
require_relative "notifier"
|
|
15
15
|
require_relative "handoff_only"
|
|
16
|
+
require_relative "handoff_host"
|
|
16
17
|
require_relative "offer_sources"
|
|
17
18
|
require_relative "handoff_target"
|
|
18
19
|
require_relative "handoff_agents"
|
|
@@ -189,32 +190,10 @@ module Portage
|
|
|
189
190
|
# "handoff_only_hosts" is the user's own list (see HandoffOnly).
|
|
190
191
|
def handoff_only?
|
|
191
192
|
@handoff_only ||= HandoffOnly.new
|
|
192
|
-
|
|
193
|
-
return true if OfferSources.retail_handoff_host?(@uri.host)
|
|
194
|
-
|
|
195
|
-
etsy_buyer_host?
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
# Phase 7 (docs/plans/buy-skill-and-local-browser.md):
|
|
199
|
-
# portage-ucp-etsy is a *seller*-side adapter — a shop owner with
|
|
200
|
-
# their own ETSY_* credentials set still reaches #adapter_flow
|
|
201
|
-
# unchanged below, the same "only your own store" rule every other
|
|
202
|
-
# adapter already gets (see the class comment at the top of this
|
|
203
|
-
# file). An ordinary buyer with no Etsy credentials of their own
|
|
204
|
-
# gets routed to hand-off here instead of a homepage fetch +
|
|
205
|
-
# platform detection that would just be scraping a stranger's
|
|
206
|
-
# listing — the buyer-side offer OfferSources::EtsyListings hands
|
|
207
|
-
# `find` has nothing this process can check out anyway.
|
|
208
|
-
def etsy_buyer_host?
|
|
209
|
-
HandoffOnly.matches_any?(@uri.host, %w[etsy.com]) && !etsy_adapter_configured?
|
|
193
|
+
HandoffHost.restricted?(@uri.host, handoff_only: @handoff_only)
|
|
210
194
|
end
|
|
211
195
|
|
|
212
|
-
def
|
|
213
|
-
platform = Portage::Ucp::Resolver::PLATFORMS.find { |p| p.name == "Etsy" }
|
|
214
|
-
return false unless platform
|
|
215
|
-
|
|
216
|
-
Portage::Ucp::Resolver.missing_env(platform, Portage::Ucp::Resolver.env_for(platform)).empty?
|
|
217
|
-
end
|
|
196
|
+
def etsy_buyer_host? = HandoffHost.etsy_buyer_host?(@uri.host)
|
|
218
197
|
|
|
219
198
|
# No checkout was ever built — there's nothing to browse or complete,
|
|
220
199
|
# just a link and why. `checkout_url` is built, never fetched: the
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "portage/ucp"
|
|
3
|
+
require_relative "handoff_only"
|
|
4
|
+
require_relative "handoff_host"
|
|
5
|
+
require_relative "check_next_step"
|
|
6
|
+
require_relative "webmcp"
|
|
7
|
+
require_relative "browser_profile"
|
|
8
|
+
|
|
9
|
+
module Portage
|
|
10
|
+
module Cli
|
|
11
|
+
# `portage check <url>` — "can Portage buy from this store, and how?"
|
|
12
|
+
#
|
|
13
|
+
# Wraps Portage::Ucp::Check (native `/.well-known/ucp`, platform
|
|
14
|
+
# detection, live adapter probe) and adds what only the CLI knows, so
|
|
15
|
+
# the answer matches what `portage buy` would actually do: the
|
|
16
|
+
# hand-off-only list (HandoffHost, shared with Buy), whether the adapter
|
|
17
|
+
# gem is installed, and whether the page exposes WebMCP tools.
|
|
18
|
+
#
|
|
19
|
+
# Precedence mirrors Buy#call: hand-off-only host, then native UCP, then
|
|
20
|
+
# WebMCP, then a platform adapter, then nothing. A hand-off-only host
|
|
21
|
+
# short-circuits before any request is made.
|
|
22
|
+
#
|
|
23
|
+
# Read-only and quiet: plain GETs only (through Ucp::Check), never a
|
|
24
|
+
# cart, never a checkout. WebMCP needs a live page, and Portage never
|
|
25
|
+
# launches a browser for this: it only reads tools from a tab the
|
|
26
|
+
# Portage browser profile already has open on the store's host
|
|
27
|
+
# (`portage browser profile open --url ...`). With no such tab, or with
|
|
28
|
+
# portage-ucp-webmcp not installed, `webmcp.status` is "skipped" and says
|
|
29
|
+
# why. A `webmcp_bridge:` can be injected instead (specs, library use).
|
|
30
|
+
class Check
|
|
31
|
+
CART_CAP = "dev.ucp.shopping.cart".freeze
|
|
32
|
+
CHECKOUT_CAP = "dev.ucp.shopping.checkout".freeze
|
|
33
|
+
|
|
34
|
+
# Verdicts `portage check` exits 0 for.
|
|
35
|
+
USABLE_VERDICTS = %w[automated webmcp].freeze
|
|
36
|
+
|
|
37
|
+
NO_TAB_REASON = "no Portage browser profile tab is open on this store " \
|
|
38
|
+
"(portage browser profile open --url URL), and check never launches a browser".freeze
|
|
39
|
+
|
|
40
|
+
def self.call(url, **) = new(url, **).call
|
|
41
|
+
|
|
42
|
+
# @param webmcp_bridge [#list_tools, nil] a bridge over the store's page.
|
|
43
|
+
# @param handoff_only [HandoffOnly]
|
|
44
|
+
# @param checker [#call] url -> Portage::Ucp::Check report (injectable).
|
|
45
|
+
def initialize(url, webmcp_bridge: nil, handoff_only: HandoffOnly.new, checker: Portage::Ucp::Check.method(:call))
|
|
46
|
+
raw = url.to_s.strip
|
|
47
|
+
@uri = URI.parse(raw =~ %r{\Ahttps?://}i ? raw : "https://#{raw}")
|
|
48
|
+
@webmcp_bridge = webmcp_bridge
|
|
49
|
+
@handoff_only = handoff_only
|
|
50
|
+
@checker = checker
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def call
|
|
54
|
+
return handoff_only_report if HandoffHost.restricted?(@uri.host, handoff_only: @handoff_only)
|
|
55
|
+
|
|
56
|
+
report = @checker.call(@uri.to_s).merge(handoff_only: false)
|
|
57
|
+
report = report.merge(adapter: adapter_for(report))
|
|
58
|
+
report = report.merge(webmcp: webmcp_for(report))
|
|
59
|
+
verdict = verdict_for(report)
|
|
60
|
+
with_index_hint(report.merge(verdict: verdict, next_step: CheckNextStep.call(verdict, report)))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# docs/plans/local-catalogue.md Phase 2: a Shopify (or native UCP)
|
|
66
|
+
# store's catalogue can be crawled into the local index. Check only
|
|
67
|
+
# names the command; it never crawls.
|
|
68
|
+
def with_index_hint(report)
|
|
69
|
+
return report unless report[:native_ucp] || report[:platform] == "Shopify"
|
|
70
|
+
|
|
71
|
+
port = @uri.port == @uri.default_port ? "" : ":#{@uri.port}"
|
|
72
|
+
report.merge(index_hint: "portage index add #{@uri.scheme}://#{@uri.host}#{port} --crawl")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def handoff_only_report
|
|
76
|
+
{ url: @uri.to_s, native_ucp: nil, platform: nil, recommended_gem: nil, handoff_only: true, adapter: nil,
|
|
77
|
+
webmcp: webmcp_skipped("#{@uri.host} is hand-off only, so it isn't probed"),
|
|
78
|
+
verdict: "handoff",
|
|
79
|
+
next_step: "#{@uri.host} restricts automated purchasing agents. Portage opens the page and you buy." }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# --- adapter ---
|
|
83
|
+
|
|
84
|
+
def adapter_for(report)
|
|
85
|
+
platform = Portage::Ucp::Resolver::PLATFORMS.find { |p| p.name == report[:platform] }
|
|
86
|
+
return nil unless platform
|
|
87
|
+
|
|
88
|
+
{ gem: platform.gem, installed: gem_installed?(platform),
|
|
89
|
+
missing_env: Portage::Ucp::Resolver.missing_env(platform, Portage::Ucp::Resolver.env_for(platform)) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Same test the live probe uses: the adapter's require_path either
|
|
93
|
+
# loads or it doesn't. Loading has no I/O.
|
|
94
|
+
def gem_installed?(platform)
|
|
95
|
+
require platform.require_path
|
|
96
|
+
true
|
|
97
|
+
rescue LoadError
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# --- webmcp ---
|
|
102
|
+
|
|
103
|
+
def webmcp_for(report)
|
|
104
|
+
return webmcp_skipped("the store speaks UCP natively") if report[:native_ucp]
|
|
105
|
+
return webmcp_skipped("portage-ucp-webmcp isn't installed") unless Webmcp.available?
|
|
106
|
+
|
|
107
|
+
bridge = @webmcp_bridge || existing_tab_bridge
|
|
108
|
+
return webmcp_skipped(NO_TAB_REASON) unless bridge
|
|
109
|
+
|
|
110
|
+
detect_webmcp(bridge)
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
{ status: "error", tools: [], reason: "#{e.class}: #{e.message}" }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def webmcp_skipped(reason) = { status: "skipped", tools: [], reason: reason }
|
|
116
|
+
|
|
117
|
+
def detect_webmcp(bridge)
|
|
118
|
+
tools = bridge.list_tools
|
|
119
|
+
names = tools.map { |tool| (tool["name"] || tool[:name]).to_s }
|
|
120
|
+
return { status: "none", tools: [], reason: "the page registers no WebMCP tools" } if names.empty?
|
|
121
|
+
|
|
122
|
+
preset = Portage::Ucp::WebMcp::Presets.detect(tools)
|
|
123
|
+
session = Portage::Ucp::WebMcp.connect(bridge: bridge, preset: preset)
|
|
124
|
+
return { status: "available", tools: names, reason: nil } if cart_and_checkout?(session)
|
|
125
|
+
|
|
126
|
+
{ status: "none", tools: names,
|
|
127
|
+
reason: "the page's WebMCP tools aren't a cart and checkout set Portage recognises" }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def cart_and_checkout?(session) = session.advertises?(CART_CAP) && session.advertises?(CHECKOUT_CAP)
|
|
131
|
+
|
|
132
|
+
# Only ever a tab that is already open on the store's host: no new tab
|
|
133
|
+
# is created, no browser is started, nothing is navigated.
|
|
134
|
+
def existing_tab_bridge
|
|
135
|
+
profile = BrowserProfile::Profile.new
|
|
136
|
+
return nil unless profile.status[:running]
|
|
137
|
+
|
|
138
|
+
tab = BrowserProfile::Cdp.list(port: profile.port).find { |t| t["type"] == "page" && same_host?(t["url"]) }
|
|
139
|
+
ws_url = tab && tab["webSocketDebuggerUrl"]
|
|
140
|
+
return nil unless ws_url
|
|
141
|
+
|
|
142
|
+
BrowserProfile::Bridge.new(socket: BrowserProfile::CdpSocket.connect(ws_url),
|
|
143
|
+
allowlist: BrowserProfile::Allowlist.new(hosts: [@uri.host]))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def same_host?(url)
|
|
147
|
+
URI(url.to_s).host == @uri.host
|
|
148
|
+
rescue URI::InvalidURIError
|
|
149
|
+
false
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# --- verdict ---
|
|
153
|
+
|
|
154
|
+
def verdict_for(report)
|
|
155
|
+
return "automated" if report[:native_ucp]
|
|
156
|
+
return "webmcp" if report.dig(:webmcp, :status) == "available"
|
|
157
|
+
return "automated" if report.dig(:live_probe, :status) == "ok"
|
|
158
|
+
return "handoff" if report[:platform]
|
|
159
|
+
|
|
160
|
+
"unsupported"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
# The plain-English `next_step` of a Check report, one sentence per
|
|
4
|
+
# verdict — kept apart from Check so its detection logic stays readable.
|
|
5
|
+
module CheckNextStep
|
|
6
|
+
def self.call(verdict, report)
|
|
7
|
+
case verdict
|
|
8
|
+
when "automated" then automated(report)
|
|
9
|
+
when "webmcp" then "Portage will build the cart; you pay in your browser."
|
|
10
|
+
when "handoff" then adapter(report)
|
|
11
|
+
else unsupported(report)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.automated(report)
|
|
16
|
+
return "Nothing to do — this store speaks UCP natively." if report[:native_ucp]
|
|
17
|
+
|
|
18
|
+
"Nothing more to set up — the #{report[:platform]} adapter (#{report.dig(:adapter, :gem)}) is installed " \
|
|
19
|
+
"and answered a live probe."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.adapter(report)
|
|
23
|
+
adapter = report[:adapter]
|
|
24
|
+
return "#{report[:url]} restricts automated purchasing agents. Portage opens the page and you buy." unless
|
|
25
|
+
adapter
|
|
26
|
+
|
|
27
|
+
actions = []
|
|
28
|
+
actions << "install #{adapter[:gem]}" unless adapter[:installed]
|
|
29
|
+
actions << "set #{adapter[:missing_env].join(', ')}" if adapter[:missing_env].any?
|
|
30
|
+
return probe_failed(report) if actions.empty?
|
|
31
|
+
|
|
32
|
+
"To automate this #{report[:platform]} store (adapters act as the store's owner), " \
|
|
33
|
+
"#{actions.join(' and ')}. Until then Portage opens the store and you buy."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.probe_failed(report)
|
|
37
|
+
reason = report.dig(:live_probe, :reason)
|
|
38
|
+
"The #{report[:platform]} adapter is set up but its live probe failed#{" (#{reason})" if reason}. " \
|
|
39
|
+
"Run `portage doctor`; until then Portage opens the store and you buy."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.unsupported(report)
|
|
43
|
+
webmcp = report[:webmcp]
|
|
44
|
+
note = webmcp[:status] == "skipped" ? " (WebMCP not checked: #{webmcp[:reason]})" : ""
|
|
45
|
+
"No UCP manifest, known platform or WebMCP cart found#{note}. Portage can only open the store " \
|
|
46
|
+
"for you to browse."
|
|
47
|
+
end
|
|
48
|
+
private_class_method :automated, :adapter, :probe_failed, :unsupported
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
module Classifier
|
|
4
|
+
# Scores the tokenized input against every node and keeps the best few
|
|
5
|
+
# (docs/plans/local-catalogue.md, Phase 5). Nodes carry hundreds of
|
|
6
|
+
# rolled-up keywords, so this is where "a generic word matches a dozen
|
|
7
|
+
# categories" gets dealt with.
|
|
8
|
+
module Ranking
|
|
9
|
+
# A node's own name words and its descendants' (`keywords`) say what
|
|
10
|
+
# the product is; its parent's (`parent_keywords`, e.g. "home",
|
|
11
|
+
# "garden") only where it is filed, so they count half as much.
|
|
12
|
+
KEYWORD_WEIGHT = 2
|
|
13
|
+
PARENT_WEIGHT = 1
|
|
14
|
+
|
|
15
|
+
# Callers treat every returned id as evidence (store routing, a
|
|
16
|
+
# browser domain's category tally), so the answer keeps only the best
|
|
17
|
+
# MAX_CATEGORIES ids and, of those, only ones scoring at least
|
|
18
|
+
# 1/CUTOFF of the best: "electric kettle" is Kitchen & Dining, not
|
|
19
|
+
# also Chairs because of "electric".
|
|
20
|
+
MAX_CATEGORIES = 3
|
|
21
|
+
CUTOFF = 2
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# @param counts [Hash{String => Integer}] word => times the input says it.
|
|
26
|
+
# @param table [Classifier::Table]
|
|
27
|
+
# @param stopped [Hash] the stoplist.
|
|
28
|
+
# @return [Array<String>] up to MAX_CATEGORIES node ids, best first.
|
|
29
|
+
def best(counts, table, stopped)
|
|
30
|
+
ranked = score(counts, table).map do |id, (score, evidence)|
|
|
31
|
+
[id, score, evidence, *tie_breakers(table.nodes[id], counts.keys, stopped)]
|
|
32
|
+
end
|
|
33
|
+
ranked = ranked.sort_by { |(_id, score, _evidence, *rest)| [-score.round(6), *rest] }
|
|
34
|
+
cut(ranked).first(MAX_CATEGORIES).map(&:first)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The first row always stays; later rows only if strong?.
|
|
38
|
+
def cut(ranked)
|
|
39
|
+
strongest = ranked.map { |row| row[2] }.max
|
|
40
|
+
ranked.each_with_index.select { |row, i| i.zero? || strong?(row[2], strongest) }.map(&:first)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Distinct input words and how often each occurs. A plural variant of
|
|
44
|
+
# a word counts as the same word ("pendant" and "Pendants").
|
|
45
|
+
def word_counts(tokens)
|
|
46
|
+
tokens.each_with_object({}) do |token, counts|
|
|
47
|
+
word = counts.keys.find { |known| Classifier.word_match?(token, known) } || token
|
|
48
|
+
counts[word] = counts.fetch(word, 0) + 1
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Hash{String => Array(Float, Float)}] node id => [score,
|
|
53
|
+
# evidence], for nodes scoring above zero. Each word counts once for
|
|
54
|
+
# a node, however many of its keywords match ("light" and "lights"
|
|
55
|
+
# are two keywords but one word of the input), so a node cannot win
|
|
56
|
+
# by listing a word's variants: KEYWORD_WEIGHT when a `keywords`
|
|
57
|
+
# entry matches, PARENT_WEIGHT when only a `parent_keywords` entry
|
|
58
|
+
# does, times how often the input says the word (1 + ln count: a
|
|
59
|
+
# Shopify tag list repeats "Pendant Lights" once per room, which is
|
|
60
|
+
# its best evidence, and the log keeps a long tag list from burying
|
|
61
|
+
# a different word). `evidence` is the same sum with each word also
|
|
62
|
+
# weighted by how rare it is (ln(1 + nodes / nodes it matches)), so
|
|
63
|
+
# "kettle" counts for more than "electric".
|
|
64
|
+
def score(counts, table)
|
|
65
|
+
scores = Hash.new { |hash, id| hash[id] = [0.0, 0.0] }
|
|
66
|
+
counts.each do |word, count|
|
|
67
|
+
contributions(word, count, table).each { |id, weight, rarity| add(scores[id], weight, rarity) }
|
|
68
|
+
end
|
|
69
|
+
scores
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Array<Array(String, Float, Float)>] [node id, weight,
|
|
73
|
+
# rarity] for every node `word` matches.
|
|
74
|
+
def contributions(word, count, table)
|
|
75
|
+
own = matching_ids(word, table.own)
|
|
76
|
+
parent = matching_ids(word, table.parent) - own
|
|
77
|
+
return [] if own.empty? && parent.empty?
|
|
78
|
+
|
|
79
|
+
tf = 1 + Math.log(count)
|
|
80
|
+
rarity = Math.log(1 + table.nodes.size.fdiv((own + parent).length))
|
|
81
|
+
own.map { |id| [id, KEYWORD_WEIGHT * tf, rarity] } + parent.map { |id| [id, PARENT_WEIGHT * tf, rarity] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def add(pair, weight, rarity)
|
|
85
|
+
pair[0] += weight
|
|
86
|
+
pair[1] += weight * rarity
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Ranking by `score` alone is what classifies a long tag list best,
|
|
90
|
+
# because rare words are mostly noise there (a room name in a
|
|
91
|
+
# lighting store's tags). The cut uses `evidence`, which a generic
|
|
92
|
+
# word cannot fill: an id beyond the first stays only if its evidence
|
|
93
|
+
# reaches 1/CUTOFF of the strongest.
|
|
94
|
+
def strong?(evidence, strongest) = (evidence * CUTOFF) - strongest > -1e-9
|
|
95
|
+
|
|
96
|
+
# What separates nodes with equal scores, best first: the share of
|
|
97
|
+
# the node's own name the input covers ("Sofas" before "Sofa
|
|
98
|
+
# Accessories" for "sofa"), then a name with no stoplisted word in it
|
|
99
|
+
# ("Household Appliances" before "Household Appliance Accessories"),
|
|
100
|
+
# then fewer keywords (the smaller node is the more specific one),
|
|
101
|
+
# then the file's own order.
|
|
102
|
+
def tie_breakers(node, words, stopped)
|
|
103
|
+
name = node["name"].to_s.split(" > ").last.to_s.downcase.split(/[^\p{Alpha}]+/)
|
|
104
|
+
.select { |word| word.length >= Classifier::MIN_WORD_LENGTH }
|
|
105
|
+
covered = name.count { |part| words.any? { |word| Classifier.word_match?(word, part) } }
|
|
106
|
+
generic = name.any? { |part| stopped.key?(part) } ? 1 : 0
|
|
107
|
+
[-covered.fdiv([name.length, 1].max), generic, Array(node["keywords"]).length, node["order"]]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @return [Array<String>] ids of the nodes in `index` with a keyword
|
|
111
|
+
# `word` matches.
|
|
112
|
+
def matching_ids(word, index)
|
|
113
|
+
keywords_matching(word).flat_map { |keyword| index.fetch(keyword, []) }.uniq
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The keywords Classifier.word_match? accepts `word` for: itself, the
|
|
117
|
+
# singular it is a plural of ("boots" -> "boot", "watches" ->
|
|
118
|
+
# "watch"), the plural of it ("boot" -> "boots"), and the "y"/"ies"
|
|
119
|
+
# swap. Looking a word's few candidate keywords up in a hash, rather
|
|
120
|
+
# than comparing every word with every keyword, keeps a long
|
|
121
|
+
# product-tag text (Index::Sources::StorefrontProducts,
|
|
122
|
+
# docs/plans/local-catalogue.md Phase 2) cheap now that nodes carry
|
|
123
|
+
# hundreds of keywords.
|
|
124
|
+
def keywords_matching(word)
|
|
125
|
+
keywords = [word, "#{word}s", "#{word}es"]
|
|
126
|
+
keywords << word.delete_suffix("s") if word.end_with?("s")
|
|
127
|
+
keywords << word.delete_suffix("es") if word.end_with?("es")
|
|
128
|
+
keywords << "#{word[0..-4]}y" if word.end_with?("ies")
|
|
129
|
+
keywords << "#{word[0..-2]}ies" if word.end_with?("y")
|
|
130
|
+
keywords
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -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
|