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
data/lib/portage/cli.rb
CHANGED
|
@@ -55,6 +55,7 @@ module Portage
|
|
|
55
55
|
portage buy --quote QUOTE_ID --yes [--json] ...
|
|
56
56
|
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
|
|
57
57
|
portage find --query "..." [--max-price N] [--limit N] [--json]
|
|
58
|
+
portage find --store URL --query "..." [--max-price N] [--json] (one store, live, read-only)
|
|
58
59
|
portage compare <url> --product-id ID [--id VALUE ...] [--results N]
|
|
59
60
|
[--max-price N] [--json]
|
|
60
61
|
portage check <url> [--json]
|
|
@@ -79,8 +80,9 @@ module Portage
|
|
|
79
80
|
portage orders reconcile [--checkout ID] [--json]
|
|
80
81
|
portage index build [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
|
|
81
82
|
portage index refresh [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
|
|
82
|
-
portage index show [--stores|--products] [--json]
|
|
83
|
-
portage index
|
|
83
|
+
portage index show [--stores|--products [--page N] [--per-page N]] [--json]
|
|
84
|
+
portage index search QUERY [--category ID] [--store HOST] [--limit N] [--json]
|
|
85
|
+
portage index add <url> [--crawl] [--json]
|
|
84
86
|
portage index remove <host> [--json]
|
|
85
87
|
portage index sources [--json]
|
|
86
88
|
portage browser import [--browser chrome|edge|brave|arc|firefox|safari] [--profile-root DIR]
|
|
@@ -187,14 +189,29 @@ module Portage
|
|
|
187
189
|
return nil
|
|
188
190
|
end
|
|
189
191
|
|
|
190
|
-
opts
|
|
192
|
+
valid_find_store?(opts) ? opts : nil
|
|
191
193
|
end
|
|
192
194
|
private_class_method :parse_find_options
|
|
193
195
|
|
|
196
|
+
# `--store` must be an http(s) URL (a bare host is read as https); anything
|
|
197
|
+
# else is a usage error rather than a silent no-op search.
|
|
198
|
+
def self.valid_find_store?(opts)
|
|
199
|
+
return true unless opts.key?(:store)
|
|
200
|
+
return true if Find.store_origin(opts[:store])
|
|
201
|
+
|
|
202
|
+
warn "portage: --store needs an http(s) store URL, got #{opts[:store].inspect}."
|
|
203
|
+
warn USAGE
|
|
204
|
+
false
|
|
205
|
+
end
|
|
206
|
+
private_class_method :valid_find_store?
|
|
207
|
+
|
|
194
208
|
def self.find_option_parser(opts)
|
|
195
209
|
opts[:proxy] = {}
|
|
196
210
|
OptionParser.new do |parser|
|
|
197
211
|
parser.on("--query QUERY") { |v| opts[:query] = v }
|
|
212
|
+
parser.on("--store URL", "Search only this store's catalogue, live and read-only (http/https)") do |v|
|
|
213
|
+
opts[:store] = v
|
|
214
|
+
end
|
|
198
215
|
parser.on("--limit N", Integer) { |v| opts[:limit] = v }
|
|
199
216
|
parser.on("--max-price N", Float) { |v| opts[:max_price] = to_minor_units(v) }
|
|
200
217
|
parser.on("--json") { opts[:json] = true }
|
|
@@ -252,6 +269,7 @@ module Portage
|
|
|
252
269
|
lines << " platform: #{report[:platform]}" if report[:platform]
|
|
253
270
|
lines << " webmcp: #{report[:webmcp][:status]}#{" (#{report[:webmcp][:reason]})" if report[:webmcp][:reason]}"
|
|
254
271
|
lines << " next: #{report[:next_step]}"
|
|
272
|
+
lines << " index: `#{report[:index_hint]}` adds its catalogue to your local index" if report[:index_hint]
|
|
255
273
|
lines.join("\n")
|
|
256
274
|
end
|
|
257
275
|
private_class_method :format_check
|
|
@@ -412,7 +430,8 @@ module Portage
|
|
|
412
430
|
parsed[:quote_record] = quote
|
|
413
431
|
buy = parsed[:buy]
|
|
414
432
|
buy.merge!(qty: quote["qty"], product_id: quote["product_id"], query: quote["query"].to_s)
|
|
415
|
-
buy.merge!(quote_total: quote["total"], quote_currency: quote["currency"]
|
|
433
|
+
buy.merge!(quote_total: quote["total"], quote_currency: quote["currency"], quote_store: quote["store"],
|
|
434
|
+
quote_title: quote["title"])
|
|
416
435
|
execute_buy(parsed, quote["store"])
|
|
417
436
|
end
|
|
418
437
|
private_class_method :buy_from_quote
|
|
@@ -1361,6 +1380,7 @@ module Portage
|
|
|
1361
1380
|
"build" => ->(argv) { run_index_build(argv, refresh: false) },
|
|
1362
1381
|
"refresh" => ->(argv) { run_index_build(argv, refresh: true) },
|
|
1363
1382
|
"show" => ->(argv) { run_index_show(argv) },
|
|
1383
|
+
"search" => ->(argv) { run_index_search(argv) },
|
|
1364
1384
|
"add" => ->(argv) { run_index_add(argv) },
|
|
1365
1385
|
"remove" => ->(argv) { run_index_remove(argv) },
|
|
1366
1386
|
"sources" => ->(argv) { run_index_sources(argv) }
|
|
@@ -1419,41 +1439,120 @@ module Portage
|
|
|
1419
1439
|
end
|
|
1420
1440
|
private_class_method :format_index_build
|
|
1421
1441
|
|
|
1442
|
+
INDEX_SHOW_PER_PAGE = 50
|
|
1443
|
+
|
|
1422
1444
|
def self.run_index_show(argv)
|
|
1423
|
-
opts = { kind: nil, json: false }
|
|
1445
|
+
opts = { kind: nil, json: false, page: 1, per_page: INDEX_SHOW_PER_PAGE }
|
|
1424
1446
|
OptionParser.new do |parser|
|
|
1425
1447
|
parser.on("--stores") { opts[:kind] = "stores" }
|
|
1426
1448
|
parser.on("--products") { opts[:kind] = "products" }
|
|
1449
|
+
parser.on("--page N", Integer) { |v| opts[:page] = [v, 1].max }
|
|
1450
|
+
parser.on("--per-page N", Integer) { |v| opts[:per_page] = [v, 1].max }
|
|
1427
1451
|
parser.on("--json") { opts[:json] = true }
|
|
1428
1452
|
end.parse!(argv)
|
|
1429
1453
|
|
|
1430
|
-
result =
|
|
1431
|
-
products: opts[:kind] == "stores" ? [] : Index::ProductStore.new.all }
|
|
1454
|
+
result = opts[:kind] == "products" ? index_products_page(opts) : index_show_all(opts[:kind])
|
|
1432
1455
|
puts opts[:json] ? JSON.pretty_generate(result) : format_index_show(result)
|
|
1433
1456
|
0
|
|
1434
1457
|
end
|
|
1435
1458
|
private_class_method :run_index_show
|
|
1436
1459
|
|
|
1460
|
+
def self.index_show_all(kind)
|
|
1461
|
+
{ stores: kind == "products" ? [] : Index::Store.new.all, products: kind == "stores" ? [] : Index::ProductStore.new.all }
|
|
1462
|
+
end
|
|
1463
|
+
private_class_method :index_show_all
|
|
1464
|
+
|
|
1465
|
+
# `--products` pages (docs/plans/local-catalogue.md Phase 2): a crawled
|
|
1466
|
+
# index can hold thousands.
|
|
1467
|
+
def self.index_products_page(opts)
|
|
1468
|
+
products = Index::ProductStore.new
|
|
1469
|
+
{ stores: [], products: products.page(opts[:page], per_page: opts[:per_page]), page: opts[:page],
|
|
1470
|
+
per_page: opts[:per_page], products_total: products.count }
|
|
1471
|
+
end
|
|
1472
|
+
private_class_method :index_products_page
|
|
1473
|
+
|
|
1437
1474
|
def self.format_index_show(result)
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
lines << "(none)" if result[:stores].empty?
|
|
1475
|
+
paged = result.key?(:page)
|
|
1476
|
+
lines = paged ? [] : index_store_lines(result[:stores])
|
|
1441
1477
|
lines << "Products:"
|
|
1442
1478
|
result[:products].each { |p| lines << " #{p['title'] || p['key']}" }
|
|
1443
1479
|
lines << "(none)" if result[:products].empty?
|
|
1480
|
+
lines << index_page_line(result) if paged
|
|
1444
1481
|
lines.join("\n")
|
|
1445
1482
|
end
|
|
1446
1483
|
private_class_method :format_index_show
|
|
1447
1484
|
|
|
1485
|
+
def self.index_store_lines(stores)
|
|
1486
|
+
lines = ["Stores:"] + stores.map { |s| " #{s['origin']} (#{Array(s['sources']).join(', ')})" }
|
|
1487
|
+
stores.empty? ? lines + ["(none)"] : lines
|
|
1488
|
+
end
|
|
1489
|
+
private_class_method :index_store_lines
|
|
1490
|
+
|
|
1491
|
+
def self.index_page_line(result)
|
|
1492
|
+
first = ((result[:page] - 1) * result[:per_page]) + 1
|
|
1493
|
+
last = first + result[:products].length - 1
|
|
1494
|
+
return "Page #{result[:page]} is past the end (#{result[:products_total]} product(s))." if last < first
|
|
1495
|
+
|
|
1496
|
+
"Showing #{first}-#{last} of #{result[:products_total]}. Next: --page #{result[:page] + 1}" \
|
|
1497
|
+
"#{" --per-page #{result[:per_page]}" unless result[:per_page] == INDEX_SHOW_PER_PAGE}"
|
|
1498
|
+
end
|
|
1499
|
+
private_class_method :index_page_line
|
|
1500
|
+
|
|
1501
|
+
# `portage index search` (docs/plans/local-catalogue.md Phase 2): local
|
|
1502
|
+
# index entries only, no request. Exits 1 with no hits.
|
|
1503
|
+
def self.run_index_search(argv)
|
|
1504
|
+
opts = { category: nil, store: nil, limit: 20, json: false }
|
|
1505
|
+
OptionParser.new do |parser|
|
|
1506
|
+
parser.on("--category ID") { |v| opts[:category] = v }
|
|
1507
|
+
parser.on("--store HOST") { |v| opts[:store] = v }
|
|
1508
|
+
parser.on("--limit N", Integer) { |v| opts[:limit] = [v, 1].max }
|
|
1509
|
+
parser.on("--json") { opts[:json] = true }
|
|
1510
|
+
end.parse!(argv)
|
|
1511
|
+
query = argv.join(" ").strip
|
|
1512
|
+
return (warn USAGE) || 1 if query.empty?
|
|
1513
|
+
|
|
1514
|
+
result = index_search(query, opts)
|
|
1515
|
+
puts opts[:json] ? JSON.pretty_generate(result) : format_index_search(result)
|
|
1516
|
+
result[:products].empty? ? 1 : 0
|
|
1517
|
+
end
|
|
1518
|
+
private_class_method :run_index_search
|
|
1519
|
+
|
|
1520
|
+
def self.index_search(query, opts)
|
|
1521
|
+
products = Index::ProductStore.new
|
|
1522
|
+
filters = opts.slice(:category, :store, :limit)
|
|
1523
|
+
hits = products.search(query, **filters).map { |entry| entry.merge("product" => Index::EntryProduct.wire(entry)) }
|
|
1524
|
+
{ query: query, engine: products.search_engine, live: false, filters: filters, products: hits }
|
|
1525
|
+
end
|
|
1526
|
+
private_class_method :index_search
|
|
1527
|
+
|
|
1528
|
+
def self.format_index_search(result)
|
|
1529
|
+
return "No index products match \"#{result[:query]}\"." if result[:products].empty?
|
|
1530
|
+
|
|
1531
|
+
lines = result[:products].map { |p| index_search_line(p) }
|
|
1532
|
+
lines << "(From the local index, not live: check the price and stock with " \
|
|
1533
|
+
"`portage find --store URL --query ...`.)"
|
|
1534
|
+
lines << "(FTS5 isn't available in this SQLite, so this was a plain text match.)" if result[:engine] == "like"
|
|
1535
|
+
lines.join("\n")
|
|
1536
|
+
end
|
|
1537
|
+
private_class_method :format_index_search
|
|
1538
|
+
|
|
1539
|
+
def self.index_search_line(product)
|
|
1540
|
+
hosts = Array(product["stores"]).filter_map { |s| URI.parse(s["origin"].to_s).host }.join(", ")
|
|
1541
|
+
["#{product['title']}#{" — #{product['brand']}" if product['brand']}", ("(#{hosts})" unless hosts.empty?),
|
|
1542
|
+
product["url"]].compact.join(" ")
|
|
1543
|
+
end
|
|
1544
|
+
private_class_method :index_search_line
|
|
1545
|
+
|
|
1448
1546
|
def self.run_index_add(argv)
|
|
1449
|
-
json = argv.delete("--json")
|
|
1547
|
+
json = !argv.delete("--json").nil?
|
|
1548
|
+
crawl = !argv.delete("--crawl").nil?
|
|
1450
1549
|
url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
1451
1550
|
unless url
|
|
1452
1551
|
warn USAGE
|
|
1453
1552
|
return 1
|
|
1454
1553
|
end
|
|
1455
1554
|
|
|
1456
|
-
result = Index::Builder.new.add(url)
|
|
1555
|
+
result = Index::Builder.new.add(url, crawl: crawl)
|
|
1457
1556
|
puts json ? JSON.pretty_generate(result) : result[:message]
|
|
1458
1557
|
result[:added] ? 0 : 1
|
|
1459
1558
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: portage-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tom Whitbread
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: irb
|
|
@@ -72,6 +72,20 @@ dependencies:
|
|
|
72
72
|
- - "~>"
|
|
73
73
|
- !ruby/object:Gem::Version
|
|
74
74
|
version: '0.1'
|
|
75
|
+
- !ruby/object:Gem::Dependency
|
|
76
|
+
name: sqlite3
|
|
77
|
+
requirement: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '2.9'
|
|
82
|
+
type: :runtime
|
|
83
|
+
prerelease: false
|
|
84
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '2.9'
|
|
75
89
|
- !ruby/object:Gem::Dependency
|
|
76
90
|
name: rspec
|
|
77
91
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -155,6 +169,8 @@ files:
|
|
|
155
169
|
- exe/portage
|
|
156
170
|
- exe/portage-console
|
|
157
171
|
- known-stores/categories.yml
|
|
172
|
+
- known-stores/category-stoplist.yml
|
|
173
|
+
- known-stores/category-synonyms.yml
|
|
158
174
|
- lib/portage/cli.rb
|
|
159
175
|
- lib/portage/cli/agent_profile_url.rb
|
|
160
176
|
- lib/portage/cli/approval_policy.rb
|
|
@@ -188,8 +204,11 @@ files:
|
|
|
188
204
|
- lib/portage/cli/check_next_step.rb
|
|
189
205
|
- lib/portage/cli/checkout_handoff.rb
|
|
190
206
|
- lib/portage/cli/classifier.rb
|
|
207
|
+
- lib/portage/cli/classifier/ranking.rb
|
|
208
|
+
- lib/portage/cli/classifier/table.rb
|
|
191
209
|
- lib/portage/cli/compare.rb
|
|
192
210
|
- lib/portage/cli/confidence_check.rb
|
|
211
|
+
- lib/portage/cli/confidence_state.rb
|
|
193
212
|
- lib/portage/cli/config.rb
|
|
194
213
|
- lib/portage/cli/console.rb
|
|
195
214
|
- lib/portage/cli/decisions.rb
|
|
@@ -212,12 +231,21 @@ files:
|
|
|
212
231
|
- lib/portage/cli/human_prompt.rb
|
|
213
232
|
- lib/portage/cli/index.rb
|
|
214
233
|
- lib/portage/cli/index/builder.rb
|
|
234
|
+
- lib/portage/cli/index/database.rb
|
|
235
|
+
- lib/portage/cli/index/entry_product.rb
|
|
215
236
|
- lib/portage/cli/index/exporter.rb
|
|
216
237
|
- lib/portage/cli/index/known_cache.rb
|
|
238
|
+
- lib/portage/cli/index/legacy_import.rb
|
|
217
239
|
- lib/portage/cli/index/product_store.rb
|
|
240
|
+
- lib/portage/cli/index/schema.rb
|
|
241
|
+
- lib/portage/cli/index/search.rb
|
|
218
242
|
- lib/portage/cli/index/sources.rb
|
|
219
243
|
- lib/portage/cli/index/sources/browser.rb
|
|
220
244
|
- lib/portage/cli/index/sources/shopify_catalog.rb
|
|
245
|
+
- lib/portage/cli/index/sources/storefront_products.rb
|
|
246
|
+
- lib/portage/cli/index/sources/storefront_products/mapper.rb
|
|
247
|
+
- lib/portage/cli/index/sources/storefront_products/pages.rb
|
|
248
|
+
- lib/portage/cli/index/sources/storefront_products/robots.rb
|
|
221
249
|
- lib/portage/cli/index/sources/stores_file.rb
|
|
222
250
|
- lib/portage/cli/index/sources/webmcp_sweep.rb
|
|
223
251
|
- lib/portage/cli/index/sources/wikidata.rb
|