portage-cli 0.1.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 +37 -1
- data/README.md +105 -0
- data/lib/portage/cli/buy.rb +75 -10
- 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/shipping_profile.rb +32 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +149 -0
- metadata +11 -11
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,7 +4,43 @@ 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
|
-
## [
|
|
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
|
+
|
|
32
|
+
## [0.2.0] - 2026-08-21
|
|
33
|
+
|
|
34
|
+
- `PORTAGE_SHIP_*` env vars (`Portage::Cli::ShippingProfile`) — configure a
|
|
35
|
+
default shipping address for `portage buy`'s own-store adapter-loopback
|
|
36
|
+
path, the same way adapter credentials already live in env. `portage buy`
|
|
37
|
+
auto-picks the cheapest priced option per fulfillment group once an address
|
|
38
|
+
is submitted; there's no interactive rate picker, since the CLI drives one
|
|
39
|
+
automated purchase rather than a conversation. The native UCP session path
|
|
40
|
+
(a third-party store over stdio/HTTP) isn't wired yet — no real UCP server
|
|
41
|
+
to verify a `fulfillment` wire shape against.
|
|
42
|
+
|
|
43
|
+
## [0.1.0] - 2026-08-14
|
|
8
44
|
|
|
9
45
|
- Initial pre-release. `portage buy <url>` — native UCP discovery first,
|
|
10
46
|
adapter fallback only when this process already has that platform's own
|
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,98 @@ 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
|
+
|
|
165
|
+
### Shipping address (own-store checkouts only)
|
|
166
|
+
|
|
167
|
+
When buying against your own store (`portage buy`'s step 2 adapter-credentials
|
|
168
|
+
fallback, described at the top of this file) and that adapter supports
|
|
169
|
+
`dev.ucp.shopping.fulfillment`, set a default shipping address via env
|
|
170
|
+
rather than a flag, same posture as adapter credentials:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
export PORTAGE_SHIP_STREET="1 Main St"
|
|
174
|
+
export PORTAGE_SHIP_CITY="Erie"
|
|
175
|
+
export PORTAGE_SHIP_REGION="PA" # optional
|
|
176
|
+
export PORTAGE_SHIP_COUNTRY="US"
|
|
177
|
+
export PORTAGE_SHIP_POSTAL_CODE="16501"
|
|
178
|
+
export PORTAGE_SHIP_FIRST_NAME="Ada" # optional
|
|
179
|
+
export PORTAGE_SHIP_LAST_NAME="Lovelace" # optional
|
|
180
|
+
export PORTAGE_SHIP_PHONE="+1..." # optional
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`street`/`city`/`country`/`postal_code` are required — a partial profile is
|
|
184
|
+
treated as no profile at all. Once the merchant prices shipping options
|
|
185
|
+
against that address, `portage buy` auto-picks the cheapest per fulfillment
|
|
186
|
+
group; there's no interactive rate picker, since this drives one automated
|
|
187
|
+
purchase. Native (non-adapter) UCP stores don't get this yet — see
|
|
188
|
+
`portage-ucp`'s design log for why.
|
|
189
|
+
|
|
85
190
|
## Buying without a URL
|
|
86
191
|
|
|
87
192
|
`portage find` and URL-less `portage buy` share one pipeline:
|
data/lib/portage/cli/buy.rb
CHANGED
|
@@ -113,7 +113,7 @@ module Portage
|
|
|
113
113
|
|
|
114
114
|
adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
|
|
115
115
|
if adapter_supports_checkout?(adapter)
|
|
116
|
-
full_buy(client_for(adapter), source: "adapter:#{platform.name}")
|
|
116
|
+
full_buy(client_for(adapter), source: "adapter:#{platform.name}", fulfillment_adapter: adapter)
|
|
117
117
|
else
|
|
118
118
|
catalog_only_adapter(adapter, platform)
|
|
119
119
|
end
|
|
@@ -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,
|
|
@@ -152,7 +152,13 @@ module Portage
|
|
|
152
152
|
|
|
153
153
|
# --- The actual buy, shared by native and adapter-loopback sources ---
|
|
154
154
|
|
|
155
|
-
|
|
155
|
+
# `fulfillment_adapter:` is only ever set by #adapter_flow (the
|
|
156
|
+
# own-store loopback path) — #native_flow has no raw Adapter object to
|
|
157
|
+
# inspect for `fulfillment_supported?`, just a Session talking to a
|
|
158
|
+
# remote store, so shipping-address/rate selection stays loopback-only
|
|
159
|
+
# for now rather than guessing at a wire shape no real UCP server has
|
|
160
|
+
# confirmed (see docs/design-log.md).
|
|
161
|
+
def full_buy(session, source:, fulfillment_adapter: nil)
|
|
156
162
|
products = safe_search(session)
|
|
157
163
|
product = select_product(products)
|
|
158
164
|
unless product
|
|
@@ -160,10 +166,68 @@ module Portage
|
|
|
160
166
|
message: no_match_message)
|
|
161
167
|
end
|
|
162
168
|
|
|
163
|
-
checkout = session.create_checkout(line_items: [{ product_id: product_id_of(product), quantity: @qty }]
|
|
169
|
+
checkout = session.create_checkout(line_items: [{ product_id: product_id_of(product), quantity: @qty }],
|
|
170
|
+
fulfillment: requested_fulfillment(fulfillment_adapter))
|
|
171
|
+
checkout = select_cheapest_shipping(session, checkout) if fulfillment_adapter
|
|
164
172
|
finish_checkout(session, source, products, checkout)
|
|
165
173
|
end
|
|
166
174
|
|
|
175
|
+
# Submits PORTAGE_SHIP_* (see Portage::Cli::ShippingProfile) as the
|
|
176
|
+
# checkout's shipping destination, but only when the adapter actually
|
|
177
|
+
# advertises dev.ucp.shopping.fulfillment — an adapter that doesn't
|
|
178
|
+
# override #fulfillment_supported? would just ignore the param anyway
|
|
179
|
+
# (Adapter#create_checkout's default), but there's no point building it
|
|
180
|
+
# at all in that case.
|
|
181
|
+
def requested_fulfillment(adapter)
|
|
182
|
+
return nil unless adapter && Portage::Ucp::Capabilities::FULFILLMENT.advertised_for?(adapter)
|
|
183
|
+
|
|
184
|
+
address = Portage::Cli::ShippingProfile.from_env
|
|
185
|
+
return nil unless address
|
|
186
|
+
|
|
187
|
+
Portage::Ucp::CheckoutFulfillment.new(
|
|
188
|
+
shipping_methods: [Portage::Ucp::FulfillmentMethod.new(
|
|
189
|
+
id: "requested", type: "shipping", line_item_ids: [],
|
|
190
|
+
destinations: [Portage::Ucp::ShippingDestination.new(id: "current", address: address)]
|
|
191
|
+
)]
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Once the merchant has priced options against the submitted address,
|
|
196
|
+
# auto-picks the cheapest unselected option per fulfillment group and
|
|
197
|
+
# submits it via #update_checkout — no interactive rate picker; a CLI
|
|
198
|
+
# driving a single automated purchase needs a deterministic default,
|
|
199
|
+
# not a prompt. A checkout with no fulfillment groups at all (no
|
|
200
|
+
# address was submitted, or the merchant hasn't priced anything yet)
|
|
201
|
+
# passes through unchanged.
|
|
202
|
+
def select_cheapest_shipping(session, checkout)
|
|
203
|
+
groups = checkout.dig("fulfillment", "methods", 0, "groups") || []
|
|
204
|
+
selections = groups.filter_map { |g| cheapest_option_selection(g) }
|
|
205
|
+
return checkout if selections.empty?
|
|
206
|
+
|
|
207
|
+
session.update_checkout(
|
|
208
|
+
checkout_id: checkout["id"], line_items: current_line_items(checkout),
|
|
209
|
+
fulfillment: Portage::Ucp::CheckoutFulfillment.new(
|
|
210
|
+
shipping_methods: [Portage::Ucp::FulfillmentMethod.new(id: "requested", type: "shipping",
|
|
211
|
+
line_item_ids: [], groups: selections)]
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def cheapest_option_selection(group)
|
|
217
|
+
return nil if group["selected_option_id"] || (group["options"] || []).empty?
|
|
218
|
+
|
|
219
|
+
cheapest = group["options"].min_by { |o| o.dig("totals", 0, "amount") || 0 }
|
|
220
|
+
Portage::Ucp::FulfillmentGroup.new(id: group["id"], line_item_ids: group["line_item_ids"],
|
|
221
|
+
selected_option_id: cheapest["id"])
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Checkout's line_items are response-shaped ({item: {id, ...}, ...});
|
|
225
|
+
# #update_checkout takes request-shaped hashes, same asymmetry
|
|
226
|
+
# #product_id_of already handles for search results.
|
|
227
|
+
def current_line_items(checkout)
|
|
228
|
+
checkout["line_items"].map { |li| { product_id: li.dig("item", "id"), quantity: li["quantity"] } }
|
|
229
|
+
end
|
|
230
|
+
|
|
167
231
|
def no_match_message
|
|
168
232
|
return "No product matched \"#{@query}\"." unless @product_id
|
|
169
233
|
|
|
@@ -179,12 +243,13 @@ module Portage
|
|
|
179
243
|
products.find { |product| product_id_of(product) == @product_id }
|
|
180
244
|
end
|
|
181
245
|
|
|
182
|
-
#
|
|
183
|
-
#
|
|
184
|
-
#
|
|
185
|
-
#
|
|
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.
|
|
186
251
|
def product_id_of(product)
|
|
187
|
-
product
|
|
252
|
+
product["id"]
|
|
188
253
|
end
|
|
189
254
|
|
|
190
255
|
def finish_checkout(session, source, products, checkout)
|
|
@@ -235,7 +300,7 @@ module Portage
|
|
|
235
300
|
end
|
|
236
301
|
|
|
237
302
|
def safe_search(session)
|
|
238
|
-
|
|
303
|
+
CatalogProducts.from(session.search_catalog(query: @query, limit: 10))
|
|
239
304
|
end
|
|
240
305
|
|
|
241
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
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require "portage/ucp"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
# Reads a shopper's default shipping address from the process environment
|
|
6
|
+
# — the same "your own credentials live in env, not on the command line"
|
|
7
|
+
# posture Resolver.env_for already uses for adapter credentials, applied
|
|
8
|
+
# here to a buyer-profile concern instead of a platform-credential one
|
|
9
|
+
# (so it lives in portage-cli, not Portage::Ucp::Resolver).
|
|
10
|
+
module ShippingProfile
|
|
11
|
+
ENV_VARS = {
|
|
12
|
+
street_address: "PORTAGE_SHIP_STREET", extended_address: "PORTAGE_SHIP_EXTENDED",
|
|
13
|
+
address_locality: "PORTAGE_SHIP_CITY", address_region: "PORTAGE_SHIP_REGION",
|
|
14
|
+
address_country: "PORTAGE_SHIP_COUNTRY", postal_code: "PORTAGE_SHIP_POSTAL_CODE",
|
|
15
|
+
first_name: "PORTAGE_SHIP_FIRST_NAME", last_name: "PORTAGE_SHIP_LAST_NAME",
|
|
16
|
+
phone_number: "PORTAGE_SHIP_PHONE"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
REQUIRED = %i[street_address address_locality address_country postal_code].freeze
|
|
20
|
+
|
|
21
|
+
# @return [Portage::Ucp::PostalAddress, nil] nil unless every required
|
|
22
|
+
# field is set — a partial profile isn't enough to submit, and this
|
|
23
|
+
# module never guesses at a missing field.
|
|
24
|
+
def self.from_env
|
|
25
|
+
attrs = ENV_VARS.filter_map { |key, var| [key, ENV.fetch(var, nil)] if ENV.key?(var) }.to_h
|
|
26
|
+
return nil unless REQUIRED.all? { |key| attrs.key?(key) }
|
|
27
|
+
|
|
28
|
+
Portage::Ucp::PostalAddress.new(**attrs)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/portage/cli/version.rb
CHANGED
data/lib/portage/cli.rb
CHANGED
|
@@ -2,8 +2,12 @@ require "optparse"
|
|
|
2
2
|
require "json"
|
|
3
3
|
|
|
4
4
|
require_relative "cli/version"
|
|
5
|
+
require_relative "cli/shipping_profile"
|
|
6
|
+
require_relative "cli/catalog_products"
|
|
5
7
|
require_relative "cli/buy"
|
|
6
8
|
require_relative "cli/find"
|
|
9
|
+
require_relative "cli/compare"
|
|
10
|
+
require_relative "cli/history"
|
|
7
11
|
|
|
8
12
|
module Portage
|
|
9
13
|
# `portage` — the single command-line entrypoint for acting as a shopper's
|
|
@@ -16,6 +20,10 @@ module Portage
|
|
|
16
20
|
[--product-id ID] [--yes] [--dry-run] [--json]
|
|
17
21
|
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
|
|
18
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]
|
|
19
27
|
USAGE
|
|
20
28
|
|
|
21
29
|
# @param argv [Array<String>]
|
|
@@ -25,6 +33,8 @@ module Portage
|
|
|
25
33
|
case command
|
|
26
34
|
when "buy" then run_buy(rest)
|
|
27
35
|
when "find" then run_find(rest)
|
|
36
|
+
when "compare" then run_compare(rest)
|
|
37
|
+
when "history" then run_history(rest)
|
|
28
38
|
else
|
|
29
39
|
warn USAGE
|
|
30
40
|
1
|
|
@@ -39,6 +49,8 @@ module Portage
|
|
|
39
49
|
|
|
40
50
|
json = options.delete(:json)
|
|
41
51
|
report = Find.new(**options).call
|
|
52
|
+
History.new.record_search(query: report[:query], offer_count: report[:offers].length,
|
|
53
|
+
message: report[:message])
|
|
42
54
|
puts json ? JSON.pretty_generate(report) : format_find(report)
|
|
43
55
|
report[:offers].any? ? 0 : 1
|
|
44
56
|
end
|
|
@@ -72,6 +84,50 @@ module Portage
|
|
|
72
84
|
def self.to_minor_units(major) = (major * 100).round
|
|
73
85
|
private_class_method :to_minor_units
|
|
74
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
|
+
|
|
75
131
|
# --- buy ---
|
|
76
132
|
|
|
77
133
|
def self.run_buy(argv)
|
|
@@ -121,11 +177,23 @@ module Portage
|
|
|
121
177
|
options = parsed[:buy].merge(url: url)
|
|
122
178
|
options[:product_id] ||= product_id
|
|
123
179
|
report = Buy.new(**options).call
|
|
180
|
+
record_purchase(report, options[:query]) if report[:checkout]
|
|
124
181
|
puts parsed[:json] ? JSON.pretty_generate(report) : format_report(report)
|
|
125
182
|
report[:checkout] || report[:browse] ? 0 : 1
|
|
126
183
|
end
|
|
127
184
|
private_class_method :execute_buy
|
|
128
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
|
+
|
|
129
197
|
def self.parse_buy_options(argv)
|
|
130
198
|
url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
131
199
|
buy = { url: url, qty: 1, yes: false, dry_run: false }
|
|
@@ -163,6 +231,73 @@ module Portage
|
|
|
163
231
|
end
|
|
164
232
|
private_class_method :add_search_options
|
|
165
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
|
+
|
|
166
301
|
# --- output ---
|
|
167
302
|
|
|
168
303
|
def self.format_report(report)
|
|
@@ -192,6 +327,20 @@ module Portage
|
|
|
192
327
|
end
|
|
193
328
|
private_class_method :offer_line
|
|
194
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
|
+
|
|
195
344
|
def self.format_price(offer)
|
|
196
345
|
return "price n/a" unless offer[:amount]
|
|
197
346
|
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
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
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: portage-ucp
|
|
@@ -16,28 +15,28 @@ dependencies:
|
|
|
16
15
|
requirements:
|
|
17
16
|
- - "~>"
|
|
18
17
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '0.
|
|
18
|
+
version: '0.4'
|
|
20
19
|
type: :runtime
|
|
21
20
|
prerelease: false
|
|
22
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
22
|
requirements:
|
|
24
23
|
- - "~>"
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '0.
|
|
25
|
+
version: '0.4'
|
|
27
26
|
- !ruby/object:Gem::Dependency
|
|
28
27
|
name: portage-ucp-client
|
|
29
28
|
requirement: !ruby/object:Gem::Requirement
|
|
30
29
|
requirements:
|
|
31
30
|
- - "~>"
|
|
32
31
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: '0.
|
|
32
|
+
version: '0.2'
|
|
34
33
|
type: :runtime
|
|
35
34
|
prerelease: false
|
|
36
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
36
|
requirements:
|
|
38
37
|
- - "~>"
|
|
39
38
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: '0.
|
|
39
|
+
version: '0.2'
|
|
41
40
|
- !ruby/object:Gem::Dependency
|
|
42
41
|
name: rspec
|
|
43
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -104,7 +103,6 @@ description: 'Ships the `portage` executable. `portage buy <url>` tries native U
|
|
|
104
103
|
ones answering /.well-known/ucp, and search their catalogs — documented APIs only,
|
|
105
104
|
no SERP scraping. Depends on portage-ucp (for platform detection via Resolver) and
|
|
106
105
|
portage-ucp-client (for the actual buy calls); no single adapter gem is a hard dependency.'
|
|
107
|
-
email:
|
|
108
106
|
executables:
|
|
109
107
|
- portage
|
|
110
108
|
extensions: []
|
|
@@ -116,9 +114,13 @@ files:
|
|
|
116
114
|
- exe/portage
|
|
117
115
|
- lib/portage/cli.rb
|
|
118
116
|
- lib/portage/cli/buy.rb
|
|
117
|
+
- lib/portage/cli/catalog_products.rb
|
|
118
|
+
- lib/portage/cli/compare.rb
|
|
119
119
|
- lib/portage/cli/find.rb
|
|
120
|
+
- lib/portage/cli/history.rb
|
|
120
121
|
- lib/portage/cli/probe_cache.rb
|
|
121
122
|
- lib/portage/cli/search_backends.rb
|
|
123
|
+
- lib/portage/cli/shipping_profile.rb
|
|
122
124
|
- lib/portage/cli/version.rb
|
|
123
125
|
homepage: https://github.com/tomtom87/Portage/tree/main/portage-cli
|
|
124
126
|
licenses:
|
|
@@ -127,7 +129,6 @@ metadata:
|
|
|
127
129
|
source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-cli
|
|
128
130
|
changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-cli/CHANGELOG.md
|
|
129
131
|
rubygems_mfa_required: 'true'
|
|
130
|
-
post_install_message:
|
|
131
132
|
rdoc_options: []
|
|
132
133
|
require_paths:
|
|
133
134
|
- lib
|
|
@@ -142,8 +143,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
142
143
|
- !ruby/object:Gem::Version
|
|
143
144
|
version: '0'
|
|
144
145
|
requirements: []
|
|
145
|
-
rubygems_version: 3.
|
|
146
|
-
signing_key:
|
|
146
|
+
rubygems_version: 3.6.9
|
|
147
147
|
specification_version: 4
|
|
148
148
|
summary: portage — one CLI command to buy from any store, native UCP or not
|
|
149
149
|
test_files: []
|