portage-cli 0.7.4 → 0.8.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 +580 -0
- data/README.md +322 -19
- data/exe/portage +5 -0
- data/exe/portage-console +3 -0
- data/known-stores/categories.yml +1263 -0
- data/lib/portage/cli/agent_profile_url.rb +30 -0
- data/lib/portage/cli/browser_import/categorize.rb +59 -0
- data/lib/portage/cli/browser_import/confirm.rb +35 -0
- data/lib/portage/cli/browser_import/domains.rb +47 -0
- data/lib/portage/cli/browser_import/filter.rb +91 -0
- data/lib/portage/cli/browser_import/importer.rb +248 -0
- data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
- data/lib/portage/cli/browser_import/prober.rb +60 -0
- data/lib/portage/cli/browser_import/profiles.rb +114 -0
- data/lib/portage/cli/browser_import/readers.rb +179 -0
- data/lib/portage/cli/browser_import/saver.rb +62 -0
- data/lib/portage/cli/browser_import/sqlite.rb +68 -0
- data/lib/portage/cli/browser_import.rb +23 -0
- data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
- data/lib/portage/cli/browser_profile/bridge.rb +120 -0
- data/lib/portage/cli/browser_profile/browsers.rb +69 -0
- data/lib/portage/cli/browser_profile/cdp.rb +67 -0
- data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
- data/lib/portage/cli/browser_profile/errors.rb +26 -0
- data/lib/portage/cli/browser_profile/launcher.rb +34 -0
- data/lib/portage/cli/browser_profile/profile.rb +93 -0
- data/lib/portage/cli/browser_profile.rb +25 -0
- data/lib/portage/cli/buy.rb +521 -29
- data/lib/portage/cli/classifier.rb +158 -0
- data/lib/portage/cli/compare.rb +3 -0
- data/lib/portage/cli/doctor.rb +195 -7
- data/lib/portage/cli/dot_env.rb +117 -0
- data/lib/portage/cli/find.rb +96 -12
- data/lib/portage/cli/handoff_agents.rb +186 -0
- data/lib/portage/cli/handoff_only.rb +94 -0
- data/lib/portage/cli/handoff_reconciler.rb +15 -1
- data/lib/portage/cli/handoff_target.rb +61 -0
- data/lib/portage/cli/index/builder.rb +335 -0
- data/lib/portage/cli/index/exporter.rb +91 -0
- data/lib/portage/cli/index/known_cache.rb +155 -0
- data/lib/portage/cli/index/product_store.rb +101 -0
- data/lib/portage/cli/index/sources/browser.rb +31 -0
- data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
- data/lib/portage/cli/index/sources/stores_file.rb +58 -0
- data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
- data/lib/portage/cli/index/sources/wikidata.rb +95 -0
- data/lib/portage/cli/index/sources.rb +44 -0
- data/lib/portage/cli/index/store.rb +109 -0
- data/lib/portage/cli/index.rb +20 -0
- data/lib/portage/cli/known_stores_url.rb +15 -0
- data/lib/portage/cli/offer_sources.rb +460 -0
- data/lib/portage/cli/payment_methods.rb +24 -3
- data/lib/portage/cli/search_backends.rb +337 -12
- data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
- data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
- data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
- data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
- data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
- data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
- data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
- data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
- data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
- data/lib/portage/cli/setup_wizard.rb +74 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli/webmcp.rb +10 -3
- data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
- data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
- data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
- data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
- data/lib/portage/cli/webmcp_mappings.rb +84 -0
- data/lib/portage/cli.rb +528 -6
- metadata +59 -2
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "timeout"
|
|
3
|
+
require "portage/ucp/client"
|
|
4
|
+
|
|
5
|
+
require_relative "agent_profile_url"
|
|
6
|
+
require_relative "buyer_context"
|
|
7
|
+
require_relative "catalog_products"
|
|
8
|
+
require_relative "user_agent"
|
|
9
|
+
require_relative "search_backends"
|
|
10
|
+
require_relative "handoff_only"
|
|
11
|
+
|
|
12
|
+
module Portage
|
|
13
|
+
module Cli
|
|
14
|
+
# A second kind of `find` backend, next to SearchBackends: those return
|
|
15
|
+
# bare candidate URLs that still need a `/.well-known/ucp` probe before
|
|
16
|
+
# anything is known about what they stock; an OfferSource answers offers
|
|
17
|
+
# directly, in the same shape Find itself builds
|
|
18
|
+
# (Find#offer — store:/source:/checkout:/product_id:/title:/amount:/
|
|
19
|
+
# currency:/url:), with no probe of its own. Find#call merges the two
|
|
20
|
+
# before Decisions.rank.
|
|
21
|
+
module OfferSources
|
|
22
|
+
# Shopify's catalog needs no key and is always on; the five retailer
|
|
23
|
+
# APIs below (Phase 7, docs/plans/buy-skill-and-local-browser.md) are
|
|
24
|
+
# each opt-in — `#available?` is false until the user sets that
|
|
25
|
+
# source's own key(s) via `portage setup`'s wizard or ~/.portage/.env
|
|
26
|
+
# directly, so a fresh install's `find` behaves exactly as before
|
|
27
|
+
# this phase existed.
|
|
28
|
+
def self.default
|
|
29
|
+
[ShopifyCatalog.new, *retailers.select(&:available?)]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.retailers
|
|
33
|
+
[WalmartAffiliate.new, EbayBrowse.new, BestBuyProducts.new, EtsyListings.new, AmazonCreators.new]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Every retailer offer source below ends in hand-off — none of them
|
|
37
|
+
# is native UCP, none has a checkout Buy can drive, and walmart.com/
|
|
38
|
+
# ebay.com/bestbuy.com have no adapter this gem ships at all. Buy#
|
|
39
|
+
# handoff_only? checks this alongside HandoffOnly's own (user-
|
|
40
|
+
# editable, Amazon-only) list so a `portage buy` against one of
|
|
41
|
+
# these offers' `store` never fetches the page first — that would
|
|
42
|
+
# just be scraping. Not user-editable, unlike HandoffOnly's list:
|
|
43
|
+
# this isn't a policy choice, it's a direct consequence of shipping
|
|
44
|
+
# a source with zero purchase automation. etsy.com is deliberately
|
|
45
|
+
# left out of this list — portage-ucp-etsy is a *seller*-side
|
|
46
|
+
# adapter, so Buy decides that one itself (see Buy#etsy_buyer_host?)
|
|
47
|
+
# rather than this module overriding a shop owner's own store.
|
|
48
|
+
RETAIL_HANDOFF_HOSTS = %w[walmart.com ebay.com bestbuy.com].freeze
|
|
49
|
+
|
|
50
|
+
def self.retail_handoff_host?(host) = HandoffOnly.matches_any?(host, RETAIL_HANDOFF_HOSTS)
|
|
51
|
+
|
|
52
|
+
# Shared by every retailer source below: an offer's `store` is the
|
|
53
|
+
# origin of the item URL the API itself returned, same idea as
|
|
54
|
+
# ShopifyCatalog#origin_of — kept as one module method instead of
|
|
55
|
+
# four copies.
|
|
56
|
+
def self.origin_of(url)
|
|
57
|
+
uri = URI.parse(url.to_s)
|
|
58
|
+
return nil unless uri.host && uri.scheme.to_s.start_with?("http")
|
|
59
|
+
|
|
60
|
+
port = uri.port == uri.default_port ? "" : ":#{uri.port}"
|
|
61
|
+
"#{uri.scheme}://#{uri.host}#{port}"
|
|
62
|
+
rescue URI::InvalidURIError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Shopify's global catalog search (`catalog.shopify.com/api/ucp/mcp`)
|
|
67
|
+
# — answers `search_catalog` anonymously with a correct agent profile,
|
|
68
|
+
# across every merchant on the platform, not just the ones a web
|
|
69
|
+
# search or `stores.yml` named (docs/ucp-tool-gating-investigation.md).
|
|
70
|
+
#
|
|
71
|
+
# Every result's `store` is the *merchant's* origin, not
|
|
72
|
+
# catalog.shopify.com — buying it then goes through the ordinary
|
|
73
|
+
# native-UCP path against that origin, the same as any other offer
|
|
74
|
+
# Find hands to `portage buy`. That means this source counts toward
|
|
75
|
+
# nothing in Find::MAX_PROBES: it never touches a merchant's own
|
|
76
|
+
# `/.well-known/ucp`, and the merchant only gets probed if the caller
|
|
77
|
+
# goes on to buy from it.
|
|
78
|
+
#
|
|
79
|
+
# IDs (live check 2026-09-28, query "hiking boots", GB/GBP): a
|
|
80
|
+
# product's own `id` is a global catalog id (`gid://shopify/p/…`) the
|
|
81
|
+
# merchant itself won't recognise, but each of its `variants[].id` is
|
|
82
|
+
# the merchant's real `gid://shopify/ProductVariant/…`, and
|
|
83
|
+
# `variants[].url` sits on the merchant's own domain. So the offer
|
|
84
|
+
# uses the first variant's id as `product_id` and its URL's origin as
|
|
85
|
+
# `store` — Buy#select_product/#line_item_id_of know how to check that
|
|
86
|
+
# variant out directly, no title re-search needed.
|
|
87
|
+
class ShopifyCatalog
|
|
88
|
+
ENDPOINT = "https://catalog.shopify.com/api/ucp/mcp".freeze
|
|
89
|
+
TIMEOUT = 5
|
|
90
|
+
|
|
91
|
+
# @param agent_profile [String, nil] nil (the default) resolves
|
|
92
|
+
# PORTAGE_AGENT_PROFILE / AgentProfileUrl::DEFAULT at call time,
|
|
93
|
+
# the same as every other caller into a real UCP server.
|
|
94
|
+
def initialize(endpoint: ENDPOINT, agent_profile: nil)
|
|
95
|
+
@endpoint = endpoint
|
|
96
|
+
@agent_profile = agent_profile
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def name = "shopify_catalog"
|
|
100
|
+
|
|
101
|
+
# One backend being down, rate-limited, or misconfigured shouldn't
|
|
102
|
+
# take the whole `find` with it — same posture as
|
|
103
|
+
# SearchBackends#urls_from. `Timeout.timeout` bounds it the same
|
|
104
|
+
# way, since neither Session nor its HTTP transport takes a
|
|
105
|
+
# deadline of their own.
|
|
106
|
+
# @return [Array<Hash>] the Find#offer shape, one per merchant
|
|
107
|
+
# variant the catalog returned; empty on any failure.
|
|
108
|
+
def offers(query, limit: 10, context: nil)
|
|
109
|
+
products = Timeout.timeout(TIMEOUT) do
|
|
110
|
+
CatalogProducts.from(
|
|
111
|
+
session.search_catalog(query: query, limit: limit, context: context || BuyerContext.from_env,
|
|
112
|
+
meta: agent_meta)
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
products.filter_map { |product| offer(product) }
|
|
116
|
+
rescue StandardError
|
|
117
|
+
[]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def session
|
|
123
|
+
@session ||= Portage::Ucp::Client.connect(url: @endpoint, headers: UserAgent.headers)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def agent_meta
|
|
127
|
+
{ agent_profile: @agent_profile || AgentProfileUrl.resolve }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def offer(product)
|
|
131
|
+
variant = Array(product["variants"]).first
|
|
132
|
+
origin = variant && origin_of(variant["url"])
|
|
133
|
+
return nil unless origin
|
|
134
|
+
|
|
135
|
+
amount, currency = price_of(product)
|
|
136
|
+
{ store: origin, source: name, checkout: nil, product_id: variant["id"], title: product["title"],
|
|
137
|
+
amount: amount, currency: currency, url: variant["url"] }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def origin_of(url)
|
|
141
|
+
uri = URI.parse(url.to_s)
|
|
142
|
+
return nil unless uri.host && uri.scheme.to_s.start_with?("http")
|
|
143
|
+
|
|
144
|
+
port = uri.port == uri.default_port ? "" : ":#{uri.port}"
|
|
145
|
+
"#{uri.scheme}://#{uri.host}#{port}"
|
|
146
|
+
rescue URI::InvalidURIError
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Same wire shape as Find#price_of — a `price_range.min` Money
|
|
151
|
+
# object, wire-hash-keyed same as every result that's passed through
|
|
152
|
+
# Dispatcher#wrap's `to_wire_h`.
|
|
153
|
+
def price_of(product)
|
|
154
|
+
range = product["price_range"]
|
|
155
|
+
return [nil, nil] unless range.is_a?(Hash) && range["min"].is_a?(Hash)
|
|
156
|
+
|
|
157
|
+
[range["min"]["amount"], range["min"]["currency"]]
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# --- Phase 7: official buyer-side retailer APIs, hand-off only -----
|
|
162
|
+
#
|
|
163
|
+
# None of these gets anywhere near a cart or a checkout — every offer
|
|
164
|
+
# they return has `checkout: false`, and its `store` is one of
|
|
165
|
+
# RETAIL_HANDOFF_HOSTS (or amazon.*, already covered by HandoffOnly),
|
|
166
|
+
# so `portage buy` against it always lands on the existing
|
|
167
|
+
# hand-off-only report rather than trying to drive the site. None of
|
|
168
|
+
# them writes into the local index either: they're not in
|
|
169
|
+
# Index::Sources::DEFAULT_NAMES (or anywhere else `portage index
|
|
170
|
+
# build` looks), which is how "nothing from these goes into
|
|
171
|
+
# products.json/stores.json" is enforced — there's simply no code
|
|
172
|
+
# path that would put one there.
|
|
173
|
+
#
|
|
174
|
+
# Every one of them is opt-in (`#available?` gated on its own env
|
|
175
|
+
# var(s), same posture as SearchBackends::Brave/GoogleCse), uses
|
|
176
|
+
# Portage::Ucp::Support::Connection — via SearchBackends.get_json,
|
|
177
|
+
# never raw Net::HTTP — with the same 5s open/read timeout every
|
|
178
|
+
# other search-shaped call in this gem gets, and swallows its own
|
|
179
|
+
# failures (no key, timeout, non-2xx, unparseable JSON) exactly like
|
|
180
|
+
# ShopifyCatalog above, so one retailer being down or misconfigured
|
|
181
|
+
# never takes `find` down with it.
|
|
182
|
+
|
|
183
|
+
# Walmart's affiliate/product-search API (`api.walmartlabs.com`) —
|
|
184
|
+
# the shape documented for the "Walmart Open API"/"Walmart Affiliate
|
|
185
|
+
# API" family. **Not live-checked** (docs/plans/
|
|
186
|
+
# buy-skill-and-local-browser.md Phase 7 progress log): this session
|
|
187
|
+
# had no API key to verify the endpoint or response shape against;
|
|
188
|
+
# confirm both against Walmart's current affiliate program docs
|
|
189
|
+
# before relying on this in production.
|
|
190
|
+
class WalmartAffiliate
|
|
191
|
+
ENDPOINT = "https://api.walmartlabs.com/v1/search".freeze
|
|
192
|
+
TIMEOUT = 5
|
|
193
|
+
STORE = "https://www.walmart.com".freeze
|
|
194
|
+
|
|
195
|
+
def initialize(api_key: ENV.fetch("WALMART_AFFILIATE_API_KEY", nil))
|
|
196
|
+
@api_key = api_key
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def name = "walmart_affiliate"
|
|
200
|
+
def available? = !@api_key.to_s.empty?
|
|
201
|
+
|
|
202
|
+
def offers(query, limit: 10, **)
|
|
203
|
+
return [] unless available?
|
|
204
|
+
|
|
205
|
+
data = Timeout.timeout(TIMEOUT) do
|
|
206
|
+
SearchBackends.get_json(URI.parse(ENDPOINT), params: { query: query, apiKey: @api_key, format: "json" })
|
|
207
|
+
end
|
|
208
|
+
Array(data && data["items"]).first(limit).filter_map { |item| offer(item) }
|
|
209
|
+
rescue StandardError
|
|
210
|
+
[]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
private
|
|
214
|
+
|
|
215
|
+
def offer(item)
|
|
216
|
+
url = item["productUrl"]
|
|
217
|
+
return nil if url.to_s.empty?
|
|
218
|
+
|
|
219
|
+
{ store: STORE, source: name, checkout: false, product_id: item["itemId"]&.to_s, title: item["name"],
|
|
220
|
+
amount: minor_units(item["salePrice"]), currency: "USD", url: url }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def minor_units(price)
|
|
224
|
+
price.is_a?(Numeric) ? (price * 100).round : nil
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# eBay's Browse API, Buy It Now only (`filter=buyingOptions:
|
|
229
|
+
# {FIXED_PRICE}`, checked again here in code since a caller shouldn't
|
|
230
|
+
# have to trust a query-string filter alone) — deliberately never
|
|
231
|
+
# touches eBay's Order API or its guest checkout, which takes raw
|
|
232
|
+
# card data. `EBAY_BROWSE_ACCESS_TOKEN` is an Application Access
|
|
233
|
+
# Token the user generates themselves (eBay Developer Program,
|
|
234
|
+
# client-credentials grant) — it expires roughly every two hours, so
|
|
235
|
+
# this class doesn't try to refresh it; a stale token just makes
|
|
236
|
+
# `available?` look true but every call fail closed (swallowed, same
|
|
237
|
+
# as any other failure).
|
|
238
|
+
class EbayBrowse
|
|
239
|
+
ENDPOINT = "https://api.ebay.com/buy/browse/v1/item_summary/search".freeze
|
|
240
|
+
TIMEOUT = 5
|
|
241
|
+
FIXED_PRICE = "FIXED_PRICE".freeze
|
|
242
|
+
|
|
243
|
+
def initialize(access_token: ENV.fetch("EBAY_BROWSE_ACCESS_TOKEN", nil),
|
|
244
|
+
marketplace: ENV.fetch("EBAY_MARKETPLACE_ID", "EBAY_US"))
|
|
245
|
+
@access_token = access_token
|
|
246
|
+
@marketplace = marketplace
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def name = "ebay_browse"
|
|
250
|
+
def available? = !@access_token.to_s.empty?
|
|
251
|
+
|
|
252
|
+
def offers(query, limit: 10, **)
|
|
253
|
+
return [] unless available?
|
|
254
|
+
|
|
255
|
+
data = Timeout.timeout(TIMEOUT) do
|
|
256
|
+
SearchBackends.get_json(
|
|
257
|
+
URI.parse(ENDPOINT),
|
|
258
|
+
params: { q: query, limit: limit, filter: "buyingOptions:{#{FIXED_PRICE}}" },
|
|
259
|
+
headers: { "Authorization" => "Bearer #{@access_token}", "X-EBAY-C-MARKETPLACE-ID" => @marketplace }
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
Array(data && data["itemSummaries"]).filter_map { |item| offer(item) }
|
|
263
|
+
rescue StandardError
|
|
264
|
+
[]
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
private
|
|
268
|
+
|
|
269
|
+
def offer(item)
|
|
270
|
+
return nil unless Array(item["buyingOptions"]).include?(FIXED_PRICE)
|
|
271
|
+
|
|
272
|
+
url = item["itemWebUrl"]
|
|
273
|
+
origin = url && OfferSources.origin_of(url)
|
|
274
|
+
return nil unless origin
|
|
275
|
+
|
|
276
|
+
price = item["price"] || {}
|
|
277
|
+
{ store: origin, source: name, checkout: false, product_id: item["itemId"], title: item["title"],
|
|
278
|
+
amount: minor_units(price["value"]), currency: price["currency"], url: url }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def minor_units(value)
|
|
282
|
+
Float(value, exception: false)&.then { |f| (f * 100).round }
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Best Buy Products API (`api.bestbuy.com`) — `show=` trims the
|
|
287
|
+
# response to just what an offer needs.
|
|
288
|
+
class BestBuyProducts
|
|
289
|
+
ENDPOINT = "https://api.bestbuy.com/v1/products".freeze
|
|
290
|
+
TIMEOUT = 5
|
|
291
|
+
STORE = "https://www.bestbuy.com".freeze
|
|
292
|
+
|
|
293
|
+
def initialize(api_key: ENV.fetch("BESTBUY_API_KEY", nil))
|
|
294
|
+
@api_key = api_key
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def name = "bestbuy_products"
|
|
298
|
+
def available? = !@api_key.to_s.empty?
|
|
299
|
+
|
|
300
|
+
def offers(query, limit: 10, **)
|
|
301
|
+
return [] unless available?
|
|
302
|
+
|
|
303
|
+
uri = URI.parse("#{ENDPOINT}(search=#{URI.encode_www_form_component(query)})")
|
|
304
|
+
data = Timeout.timeout(TIMEOUT) do
|
|
305
|
+
SearchBackends.get_json(uri, params: { apiKey: @api_key, format: "json", pageSize: limit,
|
|
306
|
+
show: "sku,name,salePrice,url" })
|
|
307
|
+
end
|
|
308
|
+
Array(data && data["products"]).first(limit).filter_map { |item| offer(item) }
|
|
309
|
+
rescue StandardError
|
|
310
|
+
[]
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
private
|
|
314
|
+
|
|
315
|
+
def offer(item)
|
|
316
|
+
url = item["url"]
|
|
317
|
+
return nil if url.to_s.empty?
|
|
318
|
+
|
|
319
|
+
{ store: STORE, source: name, checkout: false, product_id: item["sku"]&.to_s, title: item["name"],
|
|
320
|
+
amount: minor_units(item["salePrice"]), currency: "USD", url: url }
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def minor_units(price)
|
|
324
|
+
price.is_a?(Numeric) ? (price * 100).round : nil
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Etsy Open API v3's `findAllListingsActive` — buyer-side, public
|
|
329
|
+
# listing search, needs only an API key (`x-api-key`), no OAuth. This
|
|
330
|
+
# is a different surface from `portage-ucp-etsy`, which is the
|
|
331
|
+
# *seller*-side adapter a shop owner's own ETSY_* credentials drive
|
|
332
|
+
# (see Buy#etsy_buyer_host? for how the two stay out of each other's
|
|
333
|
+
# way at buy time).
|
|
334
|
+
class EtsyListings
|
|
335
|
+
ENDPOINT = "https://openapi.etsy.com/v3/application/listings/active".freeze
|
|
336
|
+
TIMEOUT = 5
|
|
337
|
+
|
|
338
|
+
def initialize(api_key: ENV.fetch("ETSY_LISTINGS_API_KEY", nil))
|
|
339
|
+
@api_key = api_key
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def name = "etsy_listings"
|
|
343
|
+
def available? = !@api_key.to_s.empty?
|
|
344
|
+
|
|
345
|
+
def offers(query, limit: 10, **)
|
|
346
|
+
return [] unless available?
|
|
347
|
+
|
|
348
|
+
data = Timeout.timeout(TIMEOUT) do
|
|
349
|
+
SearchBackends.get_json(URI.parse(ENDPOINT), params: { keywords: query, limit: limit },
|
|
350
|
+
headers: { "x-api-key" => @api_key })
|
|
351
|
+
end
|
|
352
|
+
Array(data && data["results"]).first(limit).filter_map { |item| offer(item) }
|
|
353
|
+
rescue StandardError
|
|
354
|
+
[]
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
private
|
|
358
|
+
|
|
359
|
+
def offer(item)
|
|
360
|
+
url = item["url"] || listing_url(item["listing_id"])
|
|
361
|
+
return nil if url.to_s.empty?
|
|
362
|
+
|
|
363
|
+
amount, currency = price_of(item["price"])
|
|
364
|
+
{ store: "https://www.etsy.com", source: name, checkout: false, product_id: item["listing_id"]&.to_s,
|
|
365
|
+
title: item["title"], amount: amount, currency: currency, url: url }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def listing_url(id) = id && "https://www.etsy.com/listing/#{id}"
|
|
369
|
+
|
|
370
|
+
# Etsy's Money resource: minor units already, via amount/divisor
|
|
371
|
+
# (amount: 1999, divisor: 100 == $19.99) rather than a decimal.
|
|
372
|
+
def price_of(price)
|
|
373
|
+
return [nil, nil] unless price.is_a?(Hash) && price["amount"].is_a?(Numeric)
|
|
374
|
+
|
|
375
|
+
[price["amount"], price["currency_code"]]
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Amazon Creators API — Amazon's Product Advertising API (PA-API 5)
|
|
380
|
+
# is deprecated (retiring 2026-05-15) and no longer onboards new
|
|
381
|
+
# integrations; Creators API is its OAuth2 successor
|
|
382
|
+
# (affiliate-program.amazon.com/creatorsapi/docs, checked
|
|
383
|
+
# 2026-09-28). `AMAZON_CREATORS_ACCESS_TOKEN` is a bearer token the
|
|
384
|
+
# user obtains through that OAuth flow themselves — this class
|
|
385
|
+
# doesn't implement the OAuth dance or token refresh, only the
|
|
386
|
+
# search call, the same "bring your own token" posture as
|
|
387
|
+
# EbayBrowse's Application Access Token above.
|
|
388
|
+
#
|
|
389
|
+
# **Not live-checked, best-effort schema** (docs/plans/
|
|
390
|
+
# buy-skill-and-local-browser.md Phase 7 progress log): Creators
|
|
391
|
+
# API's full request/response reference sits behind an approved
|
|
392
|
+
# Associates account this session doesn't have. The item shape below
|
|
393
|
+
# follows the long-stable PA-API `SearchItems`/`GetItems` resource
|
|
394
|
+
# names (`ASIN`, `DetailPageURL`, `Offers.Listings[0].Price`), which
|
|
395
|
+
# Amazon's own docs describe Creators API as continuing — confirm
|
|
396
|
+
# against the real docs before enabling this in production. Amazon
|
|
397
|
+
# is already Tier C (`HandoffOnly.amazon?`): every offer this
|
|
398
|
+
# returns goes through Buy's existing hand-off-only path the moment
|
|
399
|
+
# anyone tries to buy it, the same as an Amazon URL found any other
|
|
400
|
+
# way, so a schema mismatch here only means a missed offer in
|
|
401
|
+
# `find`, never a purchase-automation risk — a non-matching item is
|
|
402
|
+
# dropped by `#offer`'s own nil guard, not raised.
|
|
403
|
+
class AmazonCreators
|
|
404
|
+
ENDPOINT = "https://creators-api.amazon.com/searchItems".freeze
|
|
405
|
+
TIMEOUT = 5
|
|
406
|
+
|
|
407
|
+
def initialize(access_token: ENV.fetch("AMAZON_CREATORS_ACCESS_TOKEN", nil),
|
|
408
|
+
marketplace: ENV.fetch("AMAZON_CREATORS_MARKETPLACE", "www.amazon.com"))
|
|
409
|
+
@access_token = access_token
|
|
410
|
+
@marketplace = marketplace
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def name = "amazon_creators"
|
|
414
|
+
def available? = !@access_token.to_s.empty?
|
|
415
|
+
|
|
416
|
+
def offers(query, limit: 10, **)
|
|
417
|
+
return [] unless available?
|
|
418
|
+
|
|
419
|
+
data = Timeout.timeout(TIMEOUT) do
|
|
420
|
+
SearchBackends.get_json(URI.parse(ENDPOINT), params: { keywords: query, marketplace: @marketplace },
|
|
421
|
+
headers: { "Authorization" => "Bearer #{@access_token}",
|
|
422
|
+
"Accept" => "application/json" })
|
|
423
|
+
end
|
|
424
|
+
Array(data && (data["items"] || data["Items"])).first(limit).filter_map { |item| offer(item) }
|
|
425
|
+
rescue StandardError
|
|
426
|
+
[]
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
private
|
|
430
|
+
|
|
431
|
+
def offer(item)
|
|
432
|
+
url = item["detailPageUrl"] || item["DetailPageURL"]
|
|
433
|
+
asin = item["asin"] || item["ASIN"]
|
|
434
|
+
return nil if url.to_s.empty? || asin.to_s.empty?
|
|
435
|
+
|
|
436
|
+
amount, currency = price_of(item)
|
|
437
|
+
{ store: "https://#{@marketplace}", source: name, checkout: false, product_id: asin,
|
|
438
|
+
title: title_of(item), amount: amount, currency: currency, url: url }
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def title_of(item) = item["title"] || item.dig("ItemInfo", "Title", "DisplayValue")
|
|
442
|
+
|
|
443
|
+
def price_of(item)
|
|
444
|
+
price = price_field(item)
|
|
445
|
+
return [nil, nil] unless price.is_a?(Hash)
|
|
446
|
+
|
|
447
|
+
amount = price["amount"] || price["Amount"]
|
|
448
|
+
[amount.is_a?(Numeric) ? (amount * 100).round : nil, price["currency"] || price["Currency"]]
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def price_field(item)
|
|
452
|
+
listing = item.dig("offers", "listings", 0) || item.dig("Offers", "Listings", 0)
|
|
453
|
+
return nil unless listing.is_a?(Hash)
|
|
454
|
+
|
|
455
|
+
listing["price"] || listing["Price"]
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
end
|
|
@@ -16,6 +16,7 @@ require_relative "payment_methods/keychain_backend"
|
|
|
16
16
|
require_relative "payment_methods/secret_service_backend"
|
|
17
17
|
require_relative "payment_methods/env_backend"
|
|
18
18
|
require_relative "user_agent"
|
|
19
|
+
require_relative "handoff_only"
|
|
19
20
|
|
|
20
21
|
module Portage
|
|
21
22
|
module Cli
|
|
@@ -60,9 +61,10 @@ module Portage
|
|
|
60
61
|
|
|
61
62
|
def self.default(path: PATH, backend: detect_backend) = new(path: path, backend: backend).default
|
|
62
63
|
|
|
63
|
-
def initialize(path: PATH, backend: self.class.detect_backend)
|
|
64
|
+
def initialize(path: PATH, backend: self.class.detect_backend, handoff_only: nil)
|
|
64
65
|
@path = path
|
|
65
66
|
@backend = backend
|
|
67
|
+
@handoff_only = handoff_only || HandoffOnly.new
|
|
66
68
|
end
|
|
67
69
|
|
|
68
70
|
# @return [String, nil] the token `portage buy` should use when
|
|
@@ -132,8 +134,10 @@ module Portage
|
|
|
132
134
|
#
|
|
133
135
|
# @return [Hash] {status:, setup_url:, id:, label:} — status is
|
|
134
136
|
# "complete", "pending" (timed out — the CLI can re-poll later
|
|
135
|
-
# against the same enrollment id),
|
|
136
|
-
# `url` advertises payment enrollment)
|
|
137
|
+
# against the same enrollment id), "unsupported" (nothing at
|
|
138
|
+
# `url` advertises payment enrollment), or "handoff_only" (a Tier C
|
|
139
|
+
# host, e.g. Amazon — never even probed; docs/plans/
|
|
140
|
+
# buy-skill-and-local-browser.md Phase 5).
|
|
137
141
|
# @param scope [Hash, nil] Phase 2 per-token policy scope, bound at
|
|
138
142
|
# enrollment time (docs/plans/agentic-payments.md) — e.g.
|
|
139
143
|
# `{merchants: ["shop.example.com"], max_amount: 5000, currency: "USD"}`.
|
|
@@ -144,6 +148,9 @@ module Portage
|
|
|
144
148
|
def enroll(url, label: nil, scope: nil, poll_interval: 3, timeout: 300, sleeper: ->(s) { sleep(s) })
|
|
145
149
|
raise NotSupportedError, "headless mode has no local storage — set PORTAGE_PAYMENT_TOKEN instead" if headless?
|
|
146
150
|
|
|
151
|
+
host = handoff_only_host(url)
|
|
152
|
+
return { status: "handoff_only", host: host } if host
|
|
153
|
+
|
|
147
154
|
session = discover_session(url)
|
|
148
155
|
return { status: "unsupported" } unless session && payment_enrollment_advertised?(session)
|
|
149
156
|
|
|
@@ -196,6 +203,20 @@ module Portage
|
|
|
196
203
|
|
|
197
204
|
def stringify_keys(hash) = hash.transform_keys(&:to_s)
|
|
198
205
|
|
|
206
|
+
# Never even parses far enough to probe a hand-off-only host (Amazon
|
|
207
|
+
# by default) to see whether it supports payment enrollment — there's
|
|
208
|
+
# nothing to set up there regardless of what it answers.
|
|
209
|
+
def handoff_only_host(url)
|
|
210
|
+
uri = normalized_uri(url)
|
|
211
|
+
uri && @handoff_only.host?(uri.host) ? uri.host : nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def normalized_uri(url)
|
|
215
|
+
URI.parse(url.to_s =~ %r{\Ahttps?://}i ? url.to_s : "https://#{url}")
|
|
216
|
+
rescue URI::InvalidURIError
|
|
217
|
+
nil
|
|
218
|
+
end
|
|
219
|
+
|
|
199
220
|
# Same native-manifest-first, own-store-adapter-fallback discovery as
|
|
200
221
|
# Buy#call — duplicated rather than extracted since Buy's version is
|
|
201
222
|
# entangled with cart/checkout-specific branching this only needs the
|