portage-cli 0.2.0 → 0.3.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 +25 -0
- data/README.md +80 -0
- data/lib/portage/cli/buy.rb +8 -7
- data/lib/portage/cli/catalog_products.rb +29 -0
- data/lib/portage/cli/compare.rb +160 -0
- data/lib/portage/cli/find.rb +8 -10
- data/lib/portage/cli/history.rb +72 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +148 -0
- metadata +7 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a4a3c3b0ce94430985a4660b316c7fafb711d8814bc9497bba70d866bf927dc1
|
|
4
|
+
data.tar.gz: ac8bbf8f8f08ca0eda4962adfa8d240dae05e54b571a40b8aed68a8b3dd3ea03
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7484fb5d07dc6c4c9bcfbfb01f84909b6c0c551d3e117bb4854ca5e67049779657272ea3c5c904847d2381780b11ffd839efa0e689c9eb20d2db8bd903126737
|
|
7
|
+
data.tar.gz: 70e0a2624e6e20714c2898069e53440cb620044c513a2644f12576b3da96bd0b2be5cc6603a2774a4f229eb8e19d5724889b9353ef3fab628ec6c06587a03a00
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,31 @@ All notable changes to this project are documented here. Format loosely follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
|
|
5
5
|
pre-1.0, so APIs may still shift between minor versions.
|
|
6
6
|
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.3.0] - 2026-08-28
|
|
10
|
+
|
|
11
|
+
- Fixed: `find` and `buy` were treating `search_catalog`'s wire envelope
|
|
12
|
+
(`{"ucp" => ..., "products" => [...]}`) as the product list itself —
|
|
13
|
+
`Array(session.search_catalog(...))` wrapped the whole envelope Hash into a
|
|
14
|
+
single-element array instead of unwrapping `"products"`, so every offer
|
|
15
|
+
built from it was malformed against any real store. `Portage::Cli::CatalogProducts.from`
|
|
16
|
+
now unwraps the envelope (and the own-store adapter's raw
|
|
17
|
+
`CatalogSearchResult`) before either command touches the result.
|
|
18
|
+
- `portage compare <url> --product-id ID` (`Portage::Cli::Compare`, §22's
|
|
19
|
+
"find this same item elsewhere" mode) — resolves a named product, then runs
|
|
20
|
+
`find`'s own candidate-discovery/probe/rank pipeline against its title.
|
|
21
|
+
Every offer carries a `match:` tier (`confirmed`/`likely`/`unconfirmed`)
|
|
22
|
+
based on shared barcode/sku/`--id` identity, the origin store is excluded
|
|
23
|
+
by host, and `--results` truncates after ranking. Catalog-price only — no
|
|
24
|
+
`create_checkout` against candidate stores. Recorded to `portage history`
|
|
25
|
+
as a search.
|
|
26
|
+
- `portage history` — local purchase/search history (`Portage::Cli::History`),
|
|
27
|
+
logged automatically to `~/.portage/history.json` on every `find`/checkout-
|
|
28
|
+
reaching `buy`. `list` (`--purchases`/`--searches`, `--limit`, `--json`) and
|
|
29
|
+
`clear` (same scoping flags) subcommands. Separate from `ProbeCache`, which
|
|
30
|
+
remembers hosts, not actions.
|
|
31
|
+
|
|
7
32
|
## [0.2.0] - 2026-08-21
|
|
8
33
|
|
|
9
34
|
- `PORTAGE_SHIP_*` env vars (`Portage::Cli::ShippingProfile`) — configure a
|
data/README.md
CHANGED
|
@@ -29,6 +29,15 @@ portage find --query "burton snowboard" --max-price 400
|
|
|
29
29
|
|
|
30
30
|
`portage buy` with no URL runs that search and then buys the offer you pick.
|
|
31
31
|
|
|
32
|
+
Already have the item and want to know where else it's sold? `portage compare`
|
|
33
|
+
resolves a product you name by URL + product id, then runs the same
|
|
34
|
+
find pipeline against its title and ranks the results by how confident the
|
|
35
|
+
match is:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
portage compare https://your-shop.example --product-id prod_123 --results 5
|
|
39
|
+
```
|
|
40
|
+
|
|
32
41
|
Depends on [`portage-ucp`](https://github.com/tomtom87/Portage/tree/main/portage-ucp)
|
|
33
42
|
(for platform detection via `Resolver`) and
|
|
34
43
|
[`portage-ucp-client`](https://github.com/tomtom87/Portage/tree/main/portage-ucp-client)
|
|
@@ -60,6 +69,10 @@ portage buy <url> --query "..." [--qty N] [--payment-token TOKEN] [--product-id
|
|
|
60
69
|
[--yes] [--dry-run] [--json]
|
|
61
70
|
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
|
|
62
71
|
portage find --query "..." [--max-price N] [--limit N] [--json]
|
|
72
|
+
portage compare <url> --product-id ID [--id VALUE ...] [--results N]
|
|
73
|
+
[--max-price N] [--json]
|
|
74
|
+
portage history [list] [--purchases|--searches] [--limit N] [--json]
|
|
75
|
+
portage history clear [--purchases|--searches]
|
|
63
76
|
```
|
|
64
77
|
|
|
65
78
|
- `--query` — search term. Against the store's catalog when you name a store,
|
|
@@ -82,6 +95,73 @@ Exits `0` when a checkout completed (or a dry-run/browse/search resolved
|
|
|
82
95
|
successfully), `1` otherwise — including the "no native manifest, no adapter
|
|
83
96
|
credentials" dead-end case, so it's scriptable in CI.
|
|
84
97
|
|
|
98
|
+
### Compare
|
|
99
|
+
|
|
100
|
+
`portage compare <url> --product-id ID` finds other stores selling the same
|
|
101
|
+
item you already have. It resolves the named product, then runs `find`'s own
|
|
102
|
+
candidate-discovery/probe/rank pipeline against the product's title, scoring
|
|
103
|
+
each surviving offer instead of treating them all as equally confident hits:
|
|
104
|
+
|
|
105
|
+
- `--product-id` — required. The item to compare, at the store you name.
|
|
106
|
+
- `--id VALUE` — repeatable. A SKU, barcode (UPC/EAN/GTIN), or MPN you already
|
|
107
|
+
know, matched case-insensitively against every candidate's own identity
|
|
108
|
+
values. There's no way to tell the matcher which *kind* of identifier you
|
|
109
|
+
passed — the wire format doesn't distinguish them — so it doesn't pretend
|
|
110
|
+
to; passing one just adds it to the matching corpus. When omitted, the
|
|
111
|
+
origin product's own first variant sku/barcodes are used instead.
|
|
112
|
+
- `--results N` — how many ranked offers to return, default 5. Applies after
|
|
113
|
+
ranking, not before — a truncated result is always the *worst* N dropped,
|
|
114
|
+
never an arbitrary N. The underlying probe cap (candidate origins checked,
|
|
115
|
+
not results returned) stays `find`'s own limit and isn't exposed on this
|
|
116
|
+
subcommand.
|
|
117
|
+
- `--max-price` — same semantics as `find`'s.
|
|
118
|
+
|
|
119
|
+
Every offer carries a `match:` tier so a caller never mistakes a coincidence
|
|
120
|
+
for a confirmed match:
|
|
121
|
+
|
|
122
|
+
| Tier | Means |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| `confirmed` | Origin and candidate share a barcode value (UPC/EAN/GTIN) — the one identifier the spec treats as globally unique. |
|
|
125
|
+
| `likely` | Origin and candidate share a SKU, or an explicit `--id` hit landed on the candidate — both are "some string matched", not "a global identifier matched", so they share one tier rather than a false precision gradient. |
|
|
126
|
+
| `unconfirmed` | Same search query, nothing shared. Could be the same item; could just have a similar title. |
|
|
127
|
+
|
|
128
|
+
The origin store itself is excluded from results, matched by host (not by
|
|
129
|
+
raw origin string), so an `http://`/`https://`/trailing-slash variant of your
|
|
130
|
+
own store's URL doesn't show up as a "competitor." A `www.` variant is
|
|
131
|
+
treated as a different host, same as `find`'s own candidate dedupe — worth
|
|
132
|
+
knowing if your store answers on both.
|
|
133
|
+
|
|
134
|
+
**Known limitation: recall, not ranking, is the ceiling.** Compare searches
|
|
135
|
+
the backends using the origin product's own title — a store-specific
|
|
136
|
+
marketing string. If a backend never surfaces the competitor for that title,
|
|
137
|
+
no amount of tiering helps; every offer that *does* come back may be
|
|
138
|
+
`unconfirmed` because nothing more specific was searched. There's no
|
|
139
|
+
barcode/SKU-keyed second search pass yet.
|
|
140
|
+
|
|
141
|
+
**Catalog-price only.** No `create_checkout` step runs against any candidate
|
|
142
|
+
store — ranking uses each store's listed price, never a landed price
|
|
143
|
+
(shipping/tax included). Verifying the actual cheapest landed price would
|
|
144
|
+
mean starting a checkout on a store the shopper hasn't chosen, which risks
|
|
145
|
+
abandoned carts on someone else's site; left out of scope for now.
|
|
146
|
+
|
|
147
|
+
### History
|
|
148
|
+
|
|
149
|
+
Every `find` (and `buy`, once it reaches a search) and every `buy` that
|
|
150
|
+
reaches checkout is logged locally to `~/.portage/history.json` — most recent
|
|
151
|
+
200 entries each, purchases and searches kept separately. Browse-only `buy`
|
|
152
|
+
reports (no checkout reached) aren't logged as purchases.
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
portage history # both lists, most recent last
|
|
156
|
+
portage history list --purchases # just purchases
|
|
157
|
+
portage history list --searches --limit 20
|
|
158
|
+
portage history clear # wipe both
|
|
159
|
+
portage history clear --purchases # wipe just one
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
This is a local convenience cache, not an audit log — `portage history clear`
|
|
163
|
+
deletes it outright, and there's no server-side record.
|
|
164
|
+
|
|
85
165
|
### Shipping address (own-store checkouts only)
|
|
86
166
|
|
|
87
167
|
When buying against your own store (`portage buy`'s step 2 adapter-credentials
|
data/lib/portage/cli/buy.rb
CHANGED
|
@@ -131,7 +131,7 @@ module Portage
|
|
|
131
131
|
end
|
|
132
132
|
|
|
133
133
|
def catalog_only_adapter(adapter, platform)
|
|
134
|
-
products =
|
|
134
|
+
products = CatalogProducts.from(adapter.search_catalog(query: @query, limit: 10))
|
|
135
135
|
checkout = redirect_checkout(adapter, products)
|
|
136
136
|
build_report(
|
|
137
137
|
source: "adapter:#{platform.name}", browse: true, checkout: !!checkout,
|
|
@@ -243,12 +243,13 @@ module Portage
|
|
|
243
243
|
products.find { |product| product_id_of(product) == @product_id }
|
|
244
244
|
end
|
|
245
245
|
|
|
246
|
-
#
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
#
|
|
246
|
+
# #select_product only ever sees products from #safe_search, which reads
|
|
247
|
+
# through a Session — native remote or the own-store loopback session
|
|
248
|
+
# built via Client.for_adapter alike — so Dispatcher#wrap has already
|
|
249
|
+
# called #to_wire_h on every result; a string-keyed wire hash either
|
|
250
|
+
# way, never a raw Portage::Ucp::Product struct.
|
|
250
251
|
def product_id_of(product)
|
|
251
|
-
product
|
|
252
|
+
product["id"]
|
|
252
253
|
end
|
|
253
254
|
|
|
254
255
|
def finish_checkout(session, source, products, checkout)
|
|
@@ -299,7 +300,7 @@ module Portage
|
|
|
299
300
|
end
|
|
300
301
|
|
|
301
302
|
def safe_search(session)
|
|
302
|
-
|
|
303
|
+
CatalogProducts.from(session.search_catalog(query: @query, limit: 10))
|
|
303
304
|
end
|
|
304
305
|
|
|
305
306
|
# --- Homepage fetch (used by both the manifest-not-found path and the
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
# Unwraps whatever shape a catalog search call actually returns.
|
|
4
|
+
#
|
|
5
|
+
# Every call that goes through a Portage::Ucp::Client::Session — native
|
|
6
|
+
# remote stores and the own-store loopback session built via
|
|
7
|
+
# Client.for_adapter alike — passes through Dispatcher#wrap, which calls
|
|
8
|
+
# `to_wire_h` on the result before handing it back. So a Session's
|
|
9
|
+
# `search_catalog` always comes back as the wire-shaped envelope
|
|
10
|
+
# `{"ucp" => ..., "products" => [...]}`, never a bare array and never a
|
|
11
|
+
# raw struct.
|
|
12
|
+
#
|
|
13
|
+
# The one exception is Buy's own-store catalog-only path
|
|
14
|
+
# (`Buy#catalog_only_adapter`), which calls `Adapter#search_catalog`
|
|
15
|
+
# directly — no Session, no Dispatcher — and gets back the real
|
|
16
|
+
# `Portage::Ucp::CatalogSearchResult` the adapter built.
|
|
17
|
+
module CatalogProducts
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def from(result)
|
|
21
|
+
case result
|
|
22
|
+
when Portage::Ucp::CatalogSearchResult then result.products
|
|
23
|
+
when Hash then Array(result["products"])
|
|
24
|
+
else Array(result)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
require_relative "find"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
# `portage compare <url> --product-id ID` — §22's "find this same item
|
|
6
|
+
# elsewhere" mode. Resolves a product a shopper already has in hand, then
|
|
7
|
+
# runs Find's own candidate-discovery/probe/rank pipeline against the
|
|
8
|
+
# product's own title, scoring each surviving offer by how confident we
|
|
9
|
+
# actually are that it's the *same* item rather than just a similarly
|
|
10
|
+
# titled one.
|
|
11
|
+
#
|
|
12
|
+
# Catalog-price only: nothing here calls create_checkout against a
|
|
13
|
+
# candidate store, so a ranked offer's price is the listed price, never a
|
|
14
|
+
# landed price. See docs/plans/portage-compare.md for why that's a
|
|
15
|
+
# deliberate scope cut, not an oversight.
|
|
16
|
+
class Compare < Find
|
|
17
|
+
RESULT_LIMIT = 5
|
|
18
|
+
TIER_RANK = { confirmed: 0, likely: 1, unconfirmed: 2 }.freeze
|
|
19
|
+
|
|
20
|
+
# `identity:` is one repeatable value, not four aliases (--mpn/--sku/
|
|
21
|
+
# --upc/--gtin) for the same untyped string corpus — the wire format
|
|
22
|
+
# doesn't distinguish barcode types, so a flag name implying it could
|
|
23
|
+
# would be lying about the matcher's actual precision. `find_options:`
|
|
24
|
+
# bundles Find's own backends:/cache:/throttle: pass-through so this
|
|
25
|
+
# initializer stays under Metrics/ParameterLists without an exclusion.
|
|
26
|
+
def initialize(origin_url:, origin_product_id:, identity: [], results: RESULT_LIMIT, max_price: nil,
|
|
27
|
+
find_options: {})
|
|
28
|
+
@origin_url = origin_url
|
|
29
|
+
@origin_product_id = origin_product_id
|
|
30
|
+
@explicit_identity = identity
|
|
31
|
+
@result_limit = results
|
|
32
|
+
# `query: nil` — the real query isn't known until #call resolves the
|
|
33
|
+
# origin product; Find reads @query at #call time, not construction
|
|
34
|
+
# time, so #call can overwrite it before invoking Find#call via super.
|
|
35
|
+
super(query: nil, max_price: max_price, **find_options)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call
|
|
39
|
+
origin = resolve_origin
|
|
40
|
+
return origin if origin[:message]
|
|
41
|
+
|
|
42
|
+
@origin_host = origin[:host]
|
|
43
|
+
@identity = build_identity(origin[:product])
|
|
44
|
+
@query = origin[:title]
|
|
45
|
+
result = super
|
|
46
|
+
finish(result)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# --- Origin resolution ---
|
|
52
|
+
|
|
53
|
+
def resolve_origin
|
|
54
|
+
uri = parse_http(@origin_url)
|
|
55
|
+
return report(message: "Not a store URL: #{@origin_url.inspect}") unless uri
|
|
56
|
+
|
|
57
|
+
session = discover(origin_of(uri))
|
|
58
|
+
return report(message: "#{origin_of(uri)} doesn't speak UCP.") unless session
|
|
59
|
+
|
|
60
|
+
wrapped = session.get_product(product_id: @origin_product_id)
|
|
61
|
+
product = wrapped.is_a?(Hash) ? wrapped["product"] : nil
|
|
62
|
+
return report(message: "Product #{@origin_product_id.inspect} not found at #{origin_of(uri)}.") unless product
|
|
63
|
+
|
|
64
|
+
{ host: uri.host, title: field(product, "title"), product: product }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# --- Identity ---
|
|
68
|
+
|
|
69
|
+
# Explicit `--id` values win when given; otherwise fall back to the
|
|
70
|
+
# origin product's own first variant sku/barcodes. First-variant-only,
|
|
71
|
+
# same scope Find's own single-product reads already assume.
|
|
72
|
+
def build_identity(product)
|
|
73
|
+
variant = first_variant(product)
|
|
74
|
+
{ barcodes: normalize_all(barcode_values(variant)), sku: normalize(variant["sku"]),
|
|
75
|
+
explicit: normalize_all(@explicit_identity) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def first_variant(product) = Array(field(product, "variants")).first || {}
|
|
79
|
+
|
|
80
|
+
def barcode_values(variant) = Array(variant["barcodes"]).filter_map { |b| b.is_a?(Hash) ? b["value"] : nil }
|
|
81
|
+
|
|
82
|
+
def normalize(value)
|
|
83
|
+
value = value.to_s.strip
|
|
84
|
+
value.empty? ? nil : value.downcase
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def normalize_all(values) = values.map { |v| normalize(v) }.compact.uniq
|
|
88
|
+
|
|
89
|
+
# --- Candidate offers: stash identity + host for scoring/exclusion ---
|
|
90
|
+
|
|
91
|
+
# Find#offer only flattens id/title/price/url off the top-level
|
|
92
|
+
# product. Compare additionally needs each candidate's own variant
|
|
93
|
+
# sku/barcodes (to score against) and a normalized store host (to
|
|
94
|
+
# exclude the origin store by host rather than by string equality on
|
|
95
|
+
# `store:`, which misses scheme/www differences) — kept scoped to
|
|
96
|
+
# Compare rather than pushed into Find, since plain `find` has no use
|
|
97
|
+
# for either.
|
|
98
|
+
def offer(store, product)
|
|
99
|
+
base = super
|
|
100
|
+
return nil unless base
|
|
101
|
+
|
|
102
|
+
variant = first_variant(product)
|
|
103
|
+
identity_values = { barcodes: normalize_all(barcode_values(variant)), sku: normalize(variant["sku"]) }
|
|
104
|
+
base.merge(identity_values: identity_values, store_host: parse_http(store[:origin])&.host)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# --- Finish: exclude origin, score, rank, truncate, rewrite message ---
|
|
108
|
+
|
|
109
|
+
def finish(result)
|
|
110
|
+
offers = result[:offers].reject { |o| same_host?(o[:store_host], @origin_host) }
|
|
111
|
+
excluded_origin = result[:offers].length - offers.length
|
|
112
|
+
scored = offers.map { |o| o.merge(match: match_tier(o)) }
|
|
113
|
+
ranked = scored.sort_by { |o| [TIER_RANK[o[:match]], o[:checkout] ? 0 : 1, o[:amount] || 0] }
|
|
114
|
+
kept = ranked.first(@result_limit)
|
|
115
|
+
result.merge(offers: kept, message: compare_summary(kept, ranked.length - kept.length, excluded_origin))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def same_host?(candidate_host, origin_host)
|
|
119
|
+
candidate_host && origin_host && candidate_host.downcase == origin_host.downcase
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Three tiers, honest about what the wire format can actually prove:
|
|
123
|
+
# barcodes are the one value the spec treats as globally unique, so
|
|
124
|
+
# only a shared barcode earns :confirmed. A shared SKU is merchant-
|
|
125
|
+
# internal and collides across stores constantly, so it — and an
|
|
126
|
+
# explicit --id hit that isn't a barcode match — lands in :likely.
|
|
127
|
+
# Everything else rode in on the search query alone.
|
|
128
|
+
def match_tier(offer)
|
|
129
|
+
candidate = offer[:identity_values]
|
|
130
|
+
return :confirmed if shared_barcode?(candidate[:barcodes])
|
|
131
|
+
return :likely if shared_sku?(candidate[:sku]) || explicit_hit?(candidate)
|
|
132
|
+
|
|
133
|
+
:unconfirmed
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def shared_barcode?(candidate_barcodes) = @identity[:barcodes].intersect?(Array(candidate_barcodes))
|
|
137
|
+
|
|
138
|
+
def shared_sku?(candidate_sku) = @identity[:sku] && candidate_sku && @identity[:sku] == candidate_sku
|
|
139
|
+
|
|
140
|
+
def explicit_hit?(candidate)
|
|
141
|
+
@identity[:explicit].intersect?((Array(candidate[:barcodes]) + [candidate[:sku]]).compact)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def compare_summary(kept, truncated, excluded_origin)
|
|
145
|
+
return empty_compare_summary(excluded_origin) if kept.empty?
|
|
146
|
+
|
|
147
|
+
parts = ["Found #{kept.length} offer(s) for \"#{@query}\""]
|
|
148
|
+
parts << "#{truncated} more not shown (--results #{@result_limit})" if truncated.positive?
|
|
149
|
+
parts << "#{excluded_origin} excluded (origin store)" if excluded_origin.positive?
|
|
150
|
+
"#{parts.join(', ')}."
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def empty_compare_summary(excluded_origin)
|
|
154
|
+
message = "No comparable offers found for \"#{@query}\" outside #{@origin_host}"
|
|
155
|
+
message += ", #{excluded_origin} excluded (origin store)" if excluded_origin.positive?
|
|
156
|
+
"#{message}."
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
data/lib/portage/cli/find.rb
CHANGED
|
@@ -135,7 +135,7 @@ module Portage
|
|
|
135
135
|
# --- Step 3: ask the survivors what they stock ---
|
|
136
136
|
|
|
137
137
|
def offers_for(store)
|
|
138
|
-
products =
|
|
138
|
+
products = CatalogProducts.from(store[:session].search_catalog(query: @query, limit: PER_STORE_RESULTS))
|
|
139
139
|
products.filter_map { |product| offer(store, product) }
|
|
140
140
|
rescue StandardError
|
|
141
141
|
[]
|
|
@@ -161,10 +161,12 @@ module Portage
|
|
|
161
161
|
|
|
162
162
|
# --- Shapes ---
|
|
163
163
|
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
#
|
|
164
|
+
# Every product here comes from #offers_for, which reads through
|
|
165
|
+
# Session#search_catalog — Dispatcher#wrap has already called
|
|
166
|
+
# #to_wire_h on the result, so this is always a string-keyed wire hash,
|
|
167
|
+
# never a raw Portage::Ucp::Product struct (same posture as
|
|
168
|
+
# Buy#product_id_of), and it carries a `price_range` rather than a
|
|
169
|
+
# scalar price.
|
|
168
170
|
def price_of(product)
|
|
169
171
|
range = field(product, "price_range")
|
|
170
172
|
return [money_amount(range["min"]), range["min"]["currency"]] if range.is_a?(Hash) && range["min"].is_a?(Hash)
|
|
@@ -184,11 +186,7 @@ module Portage
|
|
|
184
186
|
|
|
185
187
|
def money_amount(price) = price["amount"]
|
|
186
188
|
|
|
187
|
-
def field(product, key)
|
|
188
|
-
return product[key] if product.is_a?(Hash)
|
|
189
|
-
|
|
190
|
-
product.respond_to?(key) ? product.public_send(key) : nil
|
|
191
|
-
end
|
|
189
|
+
def field(product, key) = product[key]
|
|
192
190
|
|
|
193
191
|
def report(**fields)
|
|
194
192
|
{ query: @query, candidates: [], stores: [], offers: [], message: nil }.merge(fields)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
# Local record of what `portage` has searched for and bought, entirely
|
|
7
|
+
# separate from ProbeCache (which remembers *hosts*, not *actions*). Two
|
|
8
|
+
# append-only, size-capped lists — purchases and searches — so `portage
|
|
9
|
+
# history list` can answer "what did I already look for" and "what did I
|
|
10
|
+
# already buy" without re-running anything, and `portage history clear`
|
|
11
|
+
# can wipe either or both.
|
|
12
|
+
class History
|
|
13
|
+
PATH = File.join(Dir.home, ".portage", "history.json").freeze
|
|
14
|
+
MAX_ENTRIES = 200
|
|
15
|
+
|
|
16
|
+
def initialize(path: PATH, now: Time.now)
|
|
17
|
+
@path = path
|
|
18
|
+
@now = now.to_i
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def record_purchase(url:, query:, checkout:, message:, checkout_status: nil, products: [])
|
|
22
|
+
append("purchases", { "url" => url, "query" => query, "checkout" => checkout,
|
|
23
|
+
"checkout_status" => checkout_status, "message" => message,
|
|
24
|
+
"products" => products, "at" => @now })
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def record_search(query:, offer_count:, message:)
|
|
28
|
+
append("searches", { "query" => query, "offer_count" => offer_count, "message" => message, "at" => @now })
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def purchases(limit: MAX_ENTRIES) = store["purchases"].last(limit)
|
|
32
|
+
|
|
33
|
+
def searches(limit: MAX_ENTRIES) = store["searches"].last(limit)
|
|
34
|
+
|
|
35
|
+
# @param kind [String, nil] "purchases", "searches", or nil for both.
|
|
36
|
+
def clear(kind: nil)
|
|
37
|
+
kinds = kind ? [kind] : %w[purchases searches]
|
|
38
|
+
kinds.each { |k| store[k] = [] }
|
|
39
|
+
write
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def append(kind, entry)
|
|
45
|
+
store[kind] = (store[kind] + [entry]).last(MAX_ENTRIES)
|
|
46
|
+
write
|
|
47
|
+
entry
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def store
|
|
51
|
+
@store ||= read
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def read
|
|
55
|
+
parsed = File.readable?(@path) ? JSON.parse(File.read(@path)) : {}
|
|
56
|
+
parsed = {} unless parsed.is_a?(Hash)
|
|
57
|
+
{ "purchases" => Array(parsed["purchases"]), "searches" => Array(parsed["searches"]) }
|
|
58
|
+
rescue StandardError
|
|
59
|
+
{ "purchases" => [], "searches" => [] }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A history that can't be written just doesn't remember this run —
|
|
63
|
+
# never a failed buy or search.
|
|
64
|
+
def write
|
|
65
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
66
|
+
File.write(@path, JSON.generate(@store))
|
|
67
|
+
rescue StandardError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/portage/cli/version.rb
CHANGED
data/lib/portage/cli.rb
CHANGED
|
@@ -3,8 +3,11 @@ require "json"
|
|
|
3
3
|
|
|
4
4
|
require_relative "cli/version"
|
|
5
5
|
require_relative "cli/shipping_profile"
|
|
6
|
+
require_relative "cli/catalog_products"
|
|
6
7
|
require_relative "cli/buy"
|
|
7
8
|
require_relative "cli/find"
|
|
9
|
+
require_relative "cli/compare"
|
|
10
|
+
require_relative "cli/history"
|
|
8
11
|
|
|
9
12
|
module Portage
|
|
10
13
|
# `portage` — the single command-line entrypoint for acting as a shopper's
|
|
@@ -17,6 +20,10 @@ module Portage
|
|
|
17
20
|
[--product-id ID] [--yes] [--dry-run] [--json]
|
|
18
21
|
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
|
|
19
22
|
portage find --query "..." [--max-price N] [--limit N] [--json]
|
|
23
|
+
portage compare <url> --product-id ID [--id VALUE ...] [--results N]
|
|
24
|
+
[--max-price N] [--json]
|
|
25
|
+
portage history [list] [--purchases|--searches] [--limit N] [--json]
|
|
26
|
+
portage history clear [--purchases|--searches]
|
|
20
27
|
USAGE
|
|
21
28
|
|
|
22
29
|
# @param argv [Array<String>]
|
|
@@ -26,6 +33,8 @@ module Portage
|
|
|
26
33
|
case command
|
|
27
34
|
when "buy" then run_buy(rest)
|
|
28
35
|
when "find" then run_find(rest)
|
|
36
|
+
when "compare" then run_compare(rest)
|
|
37
|
+
when "history" then run_history(rest)
|
|
29
38
|
else
|
|
30
39
|
warn USAGE
|
|
31
40
|
1
|
|
@@ -40,6 +49,8 @@ module Portage
|
|
|
40
49
|
|
|
41
50
|
json = options.delete(:json)
|
|
42
51
|
report = Find.new(**options).call
|
|
52
|
+
History.new.record_search(query: report[:query], offer_count: report[:offers].length,
|
|
53
|
+
message: report[:message])
|
|
43
54
|
puts json ? JSON.pretty_generate(report) : format_find(report)
|
|
44
55
|
report[:offers].any? ? 0 : 1
|
|
45
56
|
end
|
|
@@ -73,6 +84,50 @@ module Portage
|
|
|
73
84
|
def self.to_minor_units(major) = (major * 100).round
|
|
74
85
|
private_class_method :to_minor_units
|
|
75
86
|
|
|
87
|
+
# --- compare ---
|
|
88
|
+
|
|
89
|
+
def self.run_compare(argv)
|
|
90
|
+
options = parse_compare_options(argv)
|
|
91
|
+
return 1 unless options
|
|
92
|
+
|
|
93
|
+
json = options.delete(:json)
|
|
94
|
+
url = options.delete(:url)
|
|
95
|
+
report = Compare.new(origin_url: url, **options).call
|
|
96
|
+
# Recorded as a search, not a purchase — compare never checks out. The
|
|
97
|
+
# query string names the compare so `portage history list` doesn't
|
|
98
|
+
# read it as a plain text search for the origin product's own title.
|
|
99
|
+
History.new.record_search(query: "compare: #{url} (product #{options[:origin_product_id]})",
|
|
100
|
+
offer_count: report[:offers].length, message: report[:message])
|
|
101
|
+
puts json ? JSON.pretty_generate(report) : format_compare(report)
|
|
102
|
+
report[:offers].any? ? 0 : 1
|
|
103
|
+
end
|
|
104
|
+
private_class_method :run_compare
|
|
105
|
+
|
|
106
|
+
def self.parse_compare_options(argv)
|
|
107
|
+
url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
108
|
+
opts = { identity: [] }
|
|
109
|
+
compare_option_parser(opts).parse!(argv)
|
|
110
|
+
if !url || opts[:origin_product_id].to_s.strip.empty?
|
|
111
|
+
warn USAGE
|
|
112
|
+
return nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
opts[:url] = url
|
|
116
|
+
opts
|
|
117
|
+
end
|
|
118
|
+
private_class_method :parse_compare_options
|
|
119
|
+
|
|
120
|
+
def self.compare_option_parser(opts)
|
|
121
|
+
OptionParser.new do |parser|
|
|
122
|
+
parser.on("--product-id ID") { |v| opts[:origin_product_id] = v }
|
|
123
|
+
parser.on("--id VALUE") { |v| opts[:identity] << v }
|
|
124
|
+
parser.on("--results N", Integer) { |v| opts[:results] = v }
|
|
125
|
+
parser.on("--max-price N", Float) { |v| opts[:max_price] = to_minor_units(v) }
|
|
126
|
+
parser.on("--json") { opts[:json] = true }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
private_class_method :compare_option_parser
|
|
130
|
+
|
|
76
131
|
# --- buy ---
|
|
77
132
|
|
|
78
133
|
def self.run_buy(argv)
|
|
@@ -122,11 +177,23 @@ module Portage
|
|
|
122
177
|
options = parsed[:buy].merge(url: url)
|
|
123
178
|
options[:product_id] ||= product_id
|
|
124
179
|
report = Buy.new(**options).call
|
|
180
|
+
record_purchase(report, options[:query]) if report[:checkout]
|
|
125
181
|
puts parsed[:json] ? JSON.pretty_generate(report) : format_report(report)
|
|
126
182
|
report[:checkout] || report[:browse] ? 0 : 1
|
|
127
183
|
end
|
|
128
184
|
private_class_method :execute_buy
|
|
129
185
|
|
|
186
|
+
# Only checkout attempts land here — a browse-only report never reached a
|
|
187
|
+
# checkout, so it belongs to search history, not purchase history.
|
|
188
|
+
def self.record_purchase(report, query)
|
|
189
|
+
History.new.record_purchase(
|
|
190
|
+
url: report[:url], query: query, checkout: report[:checkout],
|
|
191
|
+
checkout_status: report[:checkout_status], message: report[:message],
|
|
192
|
+
products: report[:products].map { |p| product_line(p) }
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
private_class_method :record_purchase
|
|
196
|
+
|
|
130
197
|
def self.parse_buy_options(argv)
|
|
131
198
|
url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
132
199
|
buy = { url: url, qty: 1, yes: false, dry_run: false }
|
|
@@ -164,6 +231,73 @@ module Portage
|
|
|
164
231
|
end
|
|
165
232
|
private_class_method :add_search_options
|
|
166
233
|
|
|
234
|
+
# --- history ---
|
|
235
|
+
|
|
236
|
+
def self.run_history(argv)
|
|
237
|
+
sub = argv.first && !argv.first.start_with?("-") ? argv.shift : "list"
|
|
238
|
+
case sub
|
|
239
|
+
when "list" then run_history_list(argv)
|
|
240
|
+
when "clear" then run_history_clear(argv)
|
|
241
|
+
else
|
|
242
|
+
warn USAGE
|
|
243
|
+
1
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
private_class_method :run_history
|
|
247
|
+
|
|
248
|
+
def self.run_history_list(argv)
|
|
249
|
+
opts = { limit: History::MAX_ENTRIES }
|
|
250
|
+
history_option_parser(opts).parse!(argv)
|
|
251
|
+
json = opts.delete(:json)
|
|
252
|
+
kind = opts.delete(:kind)
|
|
253
|
+
history = History.new
|
|
254
|
+
result = { purchases: kind == "searches" ? [] : history.purchases(limit: opts[:limit]),
|
|
255
|
+
searches: kind == "purchases" ? [] : history.searches(limit: opts[:limit]) }
|
|
256
|
+
puts json ? JSON.pretty_generate(result) : format_history(result)
|
|
257
|
+
0
|
|
258
|
+
end
|
|
259
|
+
private_class_method :run_history_list
|
|
260
|
+
|
|
261
|
+
def self.run_history_clear(argv)
|
|
262
|
+
opts = {}
|
|
263
|
+
history_option_parser(opts).parse!(argv)
|
|
264
|
+
History.new.clear(kind: opts[:kind])
|
|
265
|
+
puts "Cleared #{opts[:kind] || 'purchase and search'} history."
|
|
266
|
+
0
|
|
267
|
+
end
|
|
268
|
+
private_class_method :run_history_clear
|
|
269
|
+
|
|
270
|
+
def self.history_option_parser(opts)
|
|
271
|
+
OptionParser.new do |parser|
|
|
272
|
+
parser.on("--purchases") { opts[:kind] = "purchases" }
|
|
273
|
+
parser.on("--searches") { opts[:kind] = "searches" }
|
|
274
|
+
parser.on("--limit N", Integer) { |v| opts[:limit] = v }
|
|
275
|
+
parser.on("--json") { opts[:json] = true }
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
private_class_method :history_option_parser
|
|
279
|
+
|
|
280
|
+
def self.format_history(result)
|
|
281
|
+
lines = ["Purchases:"]
|
|
282
|
+
result[:purchases].each { |p| lines << " #{history_purchase_line(p)}" }
|
|
283
|
+
lines << "(none)" if result[:purchases].empty?
|
|
284
|
+
lines << "Searches:"
|
|
285
|
+
result[:searches].each { |s| lines << " #{history_search_line(s)}" }
|
|
286
|
+
lines << "(none)" if result[:searches].empty?
|
|
287
|
+
lines.join("\n")
|
|
288
|
+
end
|
|
289
|
+
private_class_method :format_history
|
|
290
|
+
|
|
291
|
+
def self.history_purchase_line(entry)
|
|
292
|
+
"#{Time.at(entry['at'])} — #{entry['url']} (#{entry['query']}) — #{entry['checkout_status'] || entry['message']}"
|
|
293
|
+
end
|
|
294
|
+
private_class_method :history_purchase_line
|
|
295
|
+
|
|
296
|
+
def self.history_search_line(entry)
|
|
297
|
+
"#{Time.at(entry['at'])} — \"#{entry['query']}\" — #{entry['offer_count']} offer(s)"
|
|
298
|
+
end
|
|
299
|
+
private_class_method :history_search_line
|
|
300
|
+
|
|
167
301
|
# --- output ---
|
|
168
302
|
|
|
169
303
|
def self.format_report(report)
|
|
@@ -193,6 +327,20 @@ module Portage
|
|
|
193
327
|
end
|
|
194
328
|
private_class_method :offer_line
|
|
195
329
|
|
|
330
|
+
def self.format_compare(report)
|
|
331
|
+
lines = [report[:message].to_s]
|
|
332
|
+
report[:offers].each_with_index { |offer, index| lines << " #{index + 1}. #{compare_offer_line(offer)}" }
|
|
333
|
+
lines.join("\n")
|
|
334
|
+
end
|
|
335
|
+
private_class_method :format_compare
|
|
336
|
+
|
|
337
|
+
def self.compare_offer_line(offer)
|
|
338
|
+
parts = ["[#{offer[:match]}] #{offer[:store]} — #{offer[:title]} (#{offer[:product_id]})", format_price(offer)]
|
|
339
|
+
parts << "browse only" unless offer[:checkout]
|
|
340
|
+
parts.join(" — ")
|
|
341
|
+
end
|
|
342
|
+
private_class_method :compare_offer_line
|
|
343
|
+
|
|
196
344
|
def self.format_price(offer)
|
|
197
345
|
return "price n/a" unless offer[:amount]
|
|
198
346
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: portage-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tom Whitbread
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '0.
|
|
18
|
+
version: '0.4'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '0.
|
|
25
|
+
version: '0.4'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: portage-ucp-client
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -114,7 +114,10 @@ files:
|
|
|
114
114
|
- exe/portage
|
|
115
115
|
- lib/portage/cli.rb
|
|
116
116
|
- lib/portage/cli/buy.rb
|
|
117
|
+
- lib/portage/cli/catalog_products.rb
|
|
118
|
+
- lib/portage/cli/compare.rb
|
|
117
119
|
- lib/portage/cli/find.rb
|
|
120
|
+
- lib/portage/cli/history.rb
|
|
118
121
|
- lib/portage/cli/probe_cache.rb
|
|
119
122
|
- lib/portage/cli/search_backends.rb
|
|
120
123
|
- lib/portage/cli/shipping_profile.rb
|
|
@@ -140,7 +143,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
140
143
|
- !ruby/object:Gem::Version
|
|
141
144
|
version: '0'
|
|
142
145
|
requirements: []
|
|
143
|
-
rubygems_version:
|
|
146
|
+
rubygems_version: 3.6.9
|
|
144
147
|
specification_version: 4
|
|
145
148
|
summary: portage — one CLI command to buy from any store, native UCP or not
|
|
146
149
|
test_files: []
|