portage-cli 0.7.5 → 0.9.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 +616 -0
- data/README.md +302 -5
- data/known-stores/categories.yml +1263 -0
- data/lib/portage/cli/agent_profile_url.rb +30 -0
- data/lib/portage/cli/approval_policy.rb +56 -0
- data/lib/portage/cli/approve.rb +135 -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_opener.rb +38 -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 +559 -29
- data/lib/portage/cli/checkout_handoff.rb +5 -24
- data/lib/portage/cli/classifier.rb +158 -0
- data/lib/portage/cli/compare.rb +3 -0
- data/lib/portage/cli/doctor.rb +155 -1
- data/lib/portage/cli/dot_env.rb +55 -0
- data/lib/portage/cli/find.rb +103 -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/history.rb +47 -2
- data/lib/portage/cli/human_prompt.rb +118 -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/money.rb +18 -0
- data/lib/portage/cli/offer_choice.rb +38 -0
- data/lib/portage/cli/offer_sources.rb +460 -0
- data/lib/portage/cli/payment_methods.rb +24 -3
- data/lib/portage/cli/pick.rb +167 -0
- data/lib/portage/cli/product_page.rb +84 -0
- data/lib/portage/cli/quotes.rb +82 -0
- 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 +930 -43
- metadata +67 -2
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "portage/ucp/client"
|
|
3
|
+
|
|
4
|
+
require_relative "../user_agent"
|
|
5
|
+
require_relative "../classifier"
|
|
6
|
+
require_relative "../probe_cache"
|
|
7
|
+
require_relative "../handoff_only"
|
|
8
|
+
require_relative "store"
|
|
9
|
+
require_relative "product_store"
|
|
10
|
+
require_relative "known_cache"
|
|
11
|
+
require_relative "exporter"
|
|
12
|
+
require_relative "sources"
|
|
13
|
+
|
|
14
|
+
module Portage
|
|
15
|
+
module Cli
|
|
16
|
+
module Index
|
|
17
|
+
# `portage index build`/`refresh`/`add` — runs each configured
|
|
18
|
+
# Sources::* against the user's own machine, verifies any new origin
|
|
19
|
+
# with one `/.well-known/ucp` probe through the existing ProbeCache
|
|
20
|
+
# (throttled, capped at MAX_NEW_PROBES per run), and writes the
|
|
21
|
+
# survivors into Store/ProductStore.
|
|
22
|
+
#
|
|
23
|
+
# Never writes Portage::Ucp::Policy#merchant_allowlist, and never
|
|
24
|
+
# decides what `--yes` treats as "picked a store" — this only ever
|
|
25
|
+
# produces candidates SearchBackends::Index hands to `find`, on the
|
|
26
|
+
# same footing as any other search backend (see Store's own comment).
|
|
27
|
+
class Builder
|
|
28
|
+
CATALOG_CAP = "dev.ucp.shopping.catalog".freeze
|
|
29
|
+
CART_CAP = "dev.ucp.shopping.cart".freeze
|
|
30
|
+
CHECKOUT_CAP = "dev.ucp.shopping.checkout".freeze
|
|
31
|
+
CAPABILITY_PREFIXES = { "catalog" => CATALOG_CAP, "cart" => CART_CAP, "checkout" => CHECKOUT_CAP }.freeze
|
|
32
|
+
|
|
33
|
+
# Duplicated from Find::MAX_PROBES/THROTTLE rather than required —
|
|
34
|
+
# same reasoning as SearchBackends::Allowlist::TOTAL_CAP: this class
|
|
35
|
+
# is usable (and tested) with no dependency on find.rb.
|
|
36
|
+
MAX_NEW_PROBES = 500
|
|
37
|
+
THROTTLE = 0.1
|
|
38
|
+
STALE_AFTER = 7 * 24 * 60 * 60
|
|
39
|
+
TOP_CATEGORIES = 5
|
|
40
|
+
|
|
41
|
+
# Public so BrowserImport::Importer (Phase 3) labels a probed
|
|
42
|
+
# origin's capabilities exactly the way an index build does.
|
|
43
|
+
# @return [Array<String>] "catalog"/"cart"/"checkout", in that order.
|
|
44
|
+
def self.capabilities_of(session)
|
|
45
|
+
CAPABILITY_PREFIXES.filter_map do |label, prefix|
|
|
46
|
+
label if Array(session.capabilities).any? { |c| c == prefix || c.start_with?("#{prefix}.") }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def initialize(stores: Store.new, products: ProductStore.new, cache: ProbeCache.new, sources: nil,
|
|
51
|
+
throttle: THROTTLE, max_new_probes: MAX_NEW_PROBES, out: $stdout, now: Time.now,
|
|
52
|
+
known_cache: KnownCache.new, handoff_only: nil)
|
|
53
|
+
@stores = stores
|
|
54
|
+
@products = products
|
|
55
|
+
@cache = cache
|
|
56
|
+
@sources = sources || Sources.default
|
|
57
|
+
@throttle = throttle
|
|
58
|
+
@max_new_probes = max_new_probes
|
|
59
|
+
@out = out
|
|
60
|
+
@now = now
|
|
61
|
+
@known_cache = known_cache
|
|
62
|
+
@handoff_only = handoff_only || HandoffOnly.new
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param queries [Array<String>, nil] passed through to every
|
|
66
|
+
# source that takes one (today, just shopify_catalog).
|
|
67
|
+
# @param export [String, nil] a directory to also write a PR-ready
|
|
68
|
+
# `{stores,products}.json` into (see Exporter) — nil (the
|
|
69
|
+
# default) skips it.
|
|
70
|
+
# @return [Hash] a run summary — sources_run, candidates,
|
|
71
|
+
# new_origins_checked, verified, products_added, capped, and
|
|
72
|
+
# (with `export:`) exported.
|
|
73
|
+
def build(queries: nil, dry_run: false, export: nil)
|
|
74
|
+
sightings = gather(queries)
|
|
75
|
+
result = apply(sightings, dry_run: dry_run)
|
|
76
|
+
result[:exported] = Exporter.new(stores: @stores, products: @products).export(export) if export
|
|
77
|
+
result
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Re-verifies every entry older than 7 days (bumping last_verified
|
|
81
|
+
# only for the ones still answering), refreshes the known-stores
|
|
82
|
+
# cache unconditionally (Phase 2c — this is one of the three
|
|
83
|
+
# triggers the plan names, alongside a first-run fetch and a stale
|
|
84
|
+
# `doctor` check), then runs an ordinary #build, which itself adds
|
|
85
|
+
# anything new the sources turn up.
|
|
86
|
+
def refresh(queries: nil, dry_run: false, export: nil)
|
|
87
|
+
@known_cache.refresh! unless dry_run
|
|
88
|
+
reverify_stale(dry_run: dry_run)
|
|
89
|
+
build(queries: queries, dry_run: dry_run, export: export).merge(refreshed: true)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# `portage index add URL` — verifies and stores one origin
|
|
93
|
+
# directly, no source involved. A hand-off-only origin (Tier C,
|
|
94
|
+
# HandoffOnly) is recorded without ever probing it — the user
|
|
95
|
+
# explicitly named it, but that's still not a request this process
|
|
96
|
+
# sends.
|
|
97
|
+
def add(url)
|
|
98
|
+
origin = origin_of(url)
|
|
99
|
+
return { added: false, message: "Not a valid http(s) URL: #{url}" } unless origin
|
|
100
|
+
return store_manual_handoff_only(origin) if handoff_only_origin?(origin)
|
|
101
|
+
|
|
102
|
+
session = probe(origin)
|
|
103
|
+
store_manual(origin, session)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# `portage index remove HOST`
|
|
107
|
+
def remove(host)
|
|
108
|
+
removed = @stores.remove(host).positive?
|
|
109
|
+
{ removed: removed,
|
|
110
|
+
message: removed ? "Removed #{host} from the index." : "No index entry for #{host}." }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def gather(queries)
|
|
116
|
+
@sources.flat_map { |source| candidates_from(source, queries) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# One source failing shouldn't take the rest of the build with it —
|
|
120
|
+
# same posture as SearchBackends#urls_from/OfferSources.
|
|
121
|
+
def candidates_from(source, queries)
|
|
122
|
+
source.candidates(queries: queries).map { |c| c.merge(source: source.name) }
|
|
123
|
+
rescue StandardError
|
|
124
|
+
[]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def apply(sightings, dry_run:)
|
|
128
|
+
grouped = sightings.group_by { |s| s[:origin] }
|
|
129
|
+
probed = probe_new_origins(grouped, dry_run: dry_run)
|
|
130
|
+
products_added = dry_run ? 0 : store_products(sightings)
|
|
131
|
+
{ sources_run: @sources.map(&:name), candidates: sightings.length, products_added: products_added,
|
|
132
|
+
new_origins_checked: probed[:checked], verified: probed[:verified], capped: probed[:capped] }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def probe_new_origins(grouped, dry_run:)
|
|
136
|
+
state = { checked: [], verified: [], probes: 0, capped: false }
|
|
137
|
+
grouped.each { |origin, group| probe_one_new_origin(origin, group, state, dry_run: dry_run) }
|
|
138
|
+
state.slice(:checked, :verified, :capped)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def probe_one_new_origin(origin, group, state, dry_run:)
|
|
142
|
+
if @stores.find(origin)
|
|
143
|
+
update_existing(origin, group) unless dry_run
|
|
144
|
+
return
|
|
145
|
+
end
|
|
146
|
+
return handoff_only_new_origin(origin, group, dry_run: dry_run) if handoff_only_origin?(origin)
|
|
147
|
+
return state[:capped] = true if state[:probes] >= @max_new_probes
|
|
148
|
+
|
|
149
|
+
throttle(state[:probes])
|
|
150
|
+
state[:probes] += 1
|
|
151
|
+
state[:checked] << origin
|
|
152
|
+
progress("Probing #{origin}...")
|
|
153
|
+
session = probe(origin)
|
|
154
|
+
return unless session
|
|
155
|
+
|
|
156
|
+
state[:verified] << origin
|
|
157
|
+
store_new(origin, session, group) unless dry_run
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Never spends a probe (docs/plans/buy-skill-and-local-browser.md
|
|
161
|
+
# Phase 5) — recorded straight as `handoff_only: true`, same as a
|
|
162
|
+
# `store_new` verdict but with no capabilities and no request ever
|
|
163
|
+
# made.
|
|
164
|
+
def handoff_only_new_origin(origin, group, dry_run:)
|
|
165
|
+
return if dry_run
|
|
166
|
+
|
|
167
|
+
@stores.upsert(origin, platform: platform_of(group), capabilities: [],
|
|
168
|
+
categories: merge_categories({}, group), sources: merged_sources(nil, group),
|
|
169
|
+
last_verified: @now.to_i, handoff_only: true)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def handoff_only_origin?(origin) = @handoff_only.host?(host_of(origin))
|
|
173
|
+
|
|
174
|
+
def host_of(origin)
|
|
175
|
+
URI.parse(origin).host
|
|
176
|
+
rescue URI::InvalidURIError
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def store_manual_handoff_only(origin)
|
|
181
|
+
existing = @stores.find(origin)
|
|
182
|
+
sources = ((existing && existing["sources"]) || []) + ["manual"]
|
|
183
|
+
@stores.upsert(origin, sources: sources.uniq, last_verified: @now.to_i, handoff_only: true)
|
|
184
|
+
{ added: true, origin: origin, message: "Added #{origin} — hand-off only, never probed." }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def update_existing(origin, group)
|
|
188
|
+
existing = @stores.find(origin)
|
|
189
|
+
@stores.upsert(origin, sources: merged_sources(existing, group),
|
|
190
|
+
categories: merge_categories(existing["categories"], group))
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def store_new(origin, session, group)
|
|
194
|
+
@stores.upsert(origin, platform: platform_of(group), capabilities: capabilities_of(session),
|
|
195
|
+
categories: merge_categories({}, group), sources: merged_sources(nil, group),
|
|
196
|
+
last_verified: @now.to_i, handoff_only: false)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def store_manual(origin, session)
|
|
200
|
+
existing = @stores.find(origin)
|
|
201
|
+
sources = ((existing && existing["sources"]) || []) + ["manual"]
|
|
202
|
+
if session
|
|
203
|
+
@stores.upsert(origin, platform: existing && existing["platform"], capabilities: capabilities_of(session),
|
|
204
|
+
sources: sources.uniq, last_verified: @now.to_i, handoff_only: false)
|
|
205
|
+
{ added: true, origin: origin, message: "Added #{origin} — verified UCP." }
|
|
206
|
+
else
|
|
207
|
+
@stores.upsert(origin, sources: sources.uniq, last_verified: @now.to_i, handoff_only: true)
|
|
208
|
+
{ added: true, origin: origin,
|
|
209
|
+
message: "Added #{origin} — no /.well-known/ucp response; marked hand-off only." }
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def merged_sources(existing, group)
|
|
214
|
+
(Array(existing && existing["sources"]) + group.map { |g| g[:source] }).uniq
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def merge_categories(existing, group)
|
|
218
|
+
tally = Hash.new(0)
|
|
219
|
+
Array(existing).each { |id, weight| tally[id] += weight.to_i }
|
|
220
|
+
group.each do |sighting|
|
|
221
|
+
next unless sighting[:title]
|
|
222
|
+
|
|
223
|
+
Classifier.categories_for(sighting[:title]).each { |id| tally[id] += 1 }
|
|
224
|
+
end
|
|
225
|
+
tally.sort_by { |_id, weight| -weight }.first(TOP_CATEGORIES).to_h
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def platform_of(group)
|
|
229
|
+
"shopify" if group.any? { |g| g[:source] == "shopify_catalog" }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def capabilities_of(session) = self.class.capabilities_of(session)
|
|
233
|
+
|
|
234
|
+
def store_products(sightings)
|
|
235
|
+
eligible = sightings.select { |s| s[:title] && @stores.find(s[:origin]) }
|
|
236
|
+
eligible.each { |sighting| store_product(sighting) }
|
|
237
|
+
eligible.length
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def store_product(sighting)
|
|
241
|
+
key = product_key(sighting)
|
|
242
|
+
category = Classifier.categories_for(sighting[:title]).first
|
|
243
|
+
@products.upsert(key, origin: sighting[:origin], seen_at: @now.to_i, title: sighting[:title],
|
|
244
|
+
brand: sighting[:brand], gtin: sighting[:gtin], category: category,
|
|
245
|
+
sources: [sighting[:source]].compact)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# GTIN when a source has one (none do yet); otherwise a normalized
|
|
249
|
+
# brand+title slug, stable across runs so the same product seen
|
|
250
|
+
# again updates its entry instead of duplicating it.
|
|
251
|
+
def product_key(sighting)
|
|
252
|
+
return "gtin:#{sighting[:gtin]}" if sighting[:gtin]
|
|
253
|
+
|
|
254
|
+
slug = [sighting[:brand], sighting[:title]].compact.join(" ").downcase.gsub(/[^a-z0-9]+/, "-")
|
|
255
|
+
"title:#{slug}"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Skips by whether the origin is *currently* hand-off only
|
|
259
|
+
# (`handoff_only_origin?`, checked against live config), never by
|
|
260
|
+
# the flag a previous run happened to store — those disagree in
|
|
261
|
+
# both directions: an origin whose UCP probe simply failed is
|
|
262
|
+
# stored `handoff_only: true` too (#store_new/#store_manual) but
|
|
263
|
+
# isn't on the Tier C list and should keep getting re-verified in
|
|
264
|
+
# case it comes online, while a stale entry recorded *before* the
|
|
265
|
+
# user added its host to `handoff_only_hosts` is stored
|
|
266
|
+
# `handoff_only: false` and must stop being probed the moment that
|
|
267
|
+
# config changes, without waiting for some other source to
|
|
268
|
+
# re-sight it. An origin caught by the live check gets its stored
|
|
269
|
+
# flag flipped to match — no request, just a `last_verified` bump
|
|
270
|
+
# so it isn't re-checked again until the next stale window.
|
|
271
|
+
def reverify_stale(dry_run:)
|
|
272
|
+
probes = 0
|
|
273
|
+
@stores.all.select { |e| stale?(e) }.each do |entry|
|
|
274
|
+
origin = entry["origin"]
|
|
275
|
+
next reverify_now_handoff_only(origin, dry_run: dry_run) if handoff_only_origin?(origin)
|
|
276
|
+
break if probes >= @max_new_probes
|
|
277
|
+
|
|
278
|
+
throttle(probes)
|
|
279
|
+
probes += 1
|
|
280
|
+
progress("Re-verifying #{origin}...")
|
|
281
|
+
session = probe(origin)
|
|
282
|
+
@stores.upsert(origin, capabilities: capabilities_of(session), last_verified: @now.to_i) \
|
|
283
|
+
if session && !dry_run
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def reverify_now_handoff_only(origin, dry_run:)
|
|
288
|
+
return if dry_run
|
|
289
|
+
|
|
290
|
+
@stores.upsert(origin, handoff_only: true, last_verified: @now.to_i)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def stale?(entry)
|
|
294
|
+
verified = entry["last_verified"]
|
|
295
|
+
verified.nil? || (@now.to_i - verified.to_i) > STALE_AFTER
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# A cached *miss* saves the work; a cached hit still has to connect
|
|
299
|
+
# for the manifest details this run wants (same posture as
|
|
300
|
+
# Find#probe).
|
|
301
|
+
def probe(origin)
|
|
302
|
+
return nil if @cache.fetch(origin) == false
|
|
303
|
+
|
|
304
|
+
session = discover(origin)
|
|
305
|
+
@cache.record(origin, !session.nil?)
|
|
306
|
+
session
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def discover(origin)
|
|
310
|
+
Portage::Ucp::Client.discover(origin, headers: UserAgent.headers)
|
|
311
|
+
rescue StandardError
|
|
312
|
+
nil
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def throttle(probed)
|
|
316
|
+
sleep(@throttle) if probed.positive? && @throttle.to_f.positive?
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def progress(message)
|
|
320
|
+
@out&.puts(message)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def origin_of(url)
|
|
324
|
+
uri = URI.parse(url.to_s)
|
|
325
|
+
return nil unless uri.host && uri.scheme.to_s.start_with?("http")
|
|
326
|
+
|
|
327
|
+
port = uri.port == uri.default_port ? "" : ":#{uri.port}"
|
|
328
|
+
"#{uri.scheme}://#{uri.host}#{port}"
|
|
329
|
+
rescue URI::InvalidURIError
|
|
330
|
+
nil
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
module Index
|
|
7
|
+
# `portage index build --export DIR` — writes a PR-ready copy of the
|
|
8
|
+
# user's own local index into `DIR/{stores,products}.json`, in the
|
|
9
|
+
# same shape as `portage-cli/known-stores/` in the repo, so a
|
|
10
|
+
# maintainer's run is just "run this, `git add`, open a PR" (Decision
|
|
11
|
+
# 1a: the user builds the index, not CI).
|
|
12
|
+
#
|
|
13
|
+
# Nothing personal ships in the export. PERSONAL_SOURCES are the
|
|
14
|
+
# labels a browser import writes (`history`/`bookmark` — Phase 3 —
|
|
15
|
+
# plus Phase 2b's placeholder `browser`): a store entry whose only
|
|
16
|
+
# `sources` are personal is dropped outright, and they're stripped
|
|
17
|
+
# from the `sources` of any entry that also has a real source, so a
|
|
18
|
+
# store found both by `shopify_catalog` and by browsing history
|
|
19
|
+
# exports as if browsing history had never touched it. A product
|
|
20
|
+
# entry is filtered the same way on its own `sources` (Phase 3 adds
|
|
21
|
+
# that field — so a product page kept by `browser import
|
|
22
|
+
# --include-product-pages` never exports, even at a store a real
|
|
23
|
+
# source also found), and in any case is kept only if at least one
|
|
24
|
+
# of its `stores[].origin` survived the store filter, with its
|
|
25
|
+
# `stores` array trimmed to just those surviving origins (entries
|
|
26
|
+
# written before Phase 3 carry no `sources`, so that origin check is
|
|
27
|
+
# all they get).
|
|
28
|
+
class Exporter
|
|
29
|
+
PERSONAL_SOURCES = %w[browser history bookmark].freeze
|
|
30
|
+
|
|
31
|
+
def initialize(stores:, products:)
|
|
32
|
+
@stores = stores
|
|
33
|
+
@products = products
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Hash] stores:, products: — counts written, plus dir.
|
|
37
|
+
def export(dir)
|
|
38
|
+
FileUtils.mkdir_p(dir)
|
|
39
|
+
exportable_stores = @stores.all.filter_map { |entry| exportable_store(entry) }
|
|
40
|
+
exportable_origins = exportable_stores.map { |e| e["origin"] }
|
|
41
|
+
exportable_products = @products.all.filter_map { |entry| exportable_product(entry, exportable_origins) }
|
|
42
|
+
|
|
43
|
+
write(dir, "stores.json", exportable_stores, key: "origin")
|
|
44
|
+
write(dir, "products.json", exportable_products, key: "key")
|
|
45
|
+
|
|
46
|
+
{ dir: dir, stores: exportable_stores.length, products: exportable_products.length }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# nil (never exported) when browsing history/bookmarks are the
|
|
52
|
+
# *only* source; otherwise the entry with every personal label
|
|
53
|
+
# stripped out of its own `sources` list, so a mixed-source entry
|
|
54
|
+
# still exports — just with no trace that browsing history ever
|
|
55
|
+
# found it too.
|
|
56
|
+
def exportable_store(entry)
|
|
57
|
+
return nil if personal_only?(entry)
|
|
58
|
+
|
|
59
|
+
entry.merge("sources" => Array(entry["sources"]) - PERSONAL_SOURCES)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A product with no surviving store origin is exactly as personal
|
|
63
|
+
# as a store entry with only "browser" as its source — it only
|
|
64
|
+
# exists in the index because of something the user looked at, not
|
|
65
|
+
# something a source discovered — so it's dropped the same way.
|
|
66
|
+
def exportable_product(entry, exportable_origins)
|
|
67
|
+
return nil if personal_only?(entry)
|
|
68
|
+
|
|
69
|
+
kept_stores = Array(entry["stores"]).select { |s| exportable_origins.include?(s["origin"]) }
|
|
70
|
+
return nil if kept_stores.empty?
|
|
71
|
+
|
|
72
|
+
exported = entry.merge("stores" => kept_stores)
|
|
73
|
+
entry.key?("sources") ? exported.merge("sources" => Array(entry["sources"]) - PERSONAL_SOURCES) : exported
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# True when the entry names at least one source and every one of
|
|
77
|
+
# them is personal. An entry with no `sources` (a product written
|
|
78
|
+
# before Phase 3) or an empty list isn't judged here at all.
|
|
79
|
+
def personal_only?(entry)
|
|
80
|
+
sources = Array(entry["sources"])
|
|
81
|
+
!sources.empty? && (sources - PERSONAL_SOURCES).empty?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def write(dir, filename, entries, key:)
|
|
85
|
+
data = entries.to_h { |entry| [entry[key], entry] }
|
|
86
|
+
File.write(File.join(dir, filename), JSON.generate(data))
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "portage/ucp/support/connection"
|
|
7
|
+
|
|
8
|
+
require_relative "../known_stores_url"
|
|
9
|
+
require_relative "../user_agent"
|
|
10
|
+
|
|
11
|
+
module Portage
|
|
12
|
+
module Cli
|
|
13
|
+
module Index
|
|
14
|
+
# The repo's own `known-stores/{stores,products}.json`, fetched over
|
|
15
|
+
# jsdelivr `@main` and cached at `~/.portage/index/known-{stores,
|
|
16
|
+
# products}.json` (docs/plans/buy-skill-and-local-browser.md Phase
|
|
17
|
+
# 2c). Same untrusted-data posture as Store/ProductStore — see their
|
|
18
|
+
# own comments at the top of this directory — plus one more rule:
|
|
19
|
+
# the user's own entries always win, so SearchBackends::Index merges
|
|
20
|
+
# this cache *underneath* Store/ProductStore rather than the other
|
|
21
|
+
# way round; this class never writes into Store/ProductStore itself.
|
|
22
|
+
#
|
|
23
|
+
# Fetched lazily the first time a caller needs it and no cache file
|
|
24
|
+
# exists yet (SearchBackends::Index#available?/#search), refreshed
|
|
25
|
+
# unconditionally by `portage index refresh`, and refreshed by
|
|
26
|
+
# `doctor` when it finds the cache older than STALE_AFTER. A fetch
|
|
27
|
+
# failure — offline, timeout, bad JSON, a non-2xx response — is
|
|
28
|
+
# swallowed exactly like SearchBackends/OfferSources::ShopifyCatalog:
|
|
29
|
+
# a stale or missing cache just means `find` works the way it did
|
|
30
|
+
# before this phase.
|
|
31
|
+
class KnownCache
|
|
32
|
+
STORES_PATH = File.join(Dir.home, ".portage", "index", "known-stores.json").freeze
|
|
33
|
+
PRODUCTS_PATH = File.join(Dir.home, ".portage", "index", "known-products.json").freeze
|
|
34
|
+
|
|
35
|
+
STALE_AFTER = 7 * 24 * 60 * 60
|
|
36
|
+
TIMEOUT = 5
|
|
37
|
+
|
|
38
|
+
# No known-stores/products entry may carry a price or stock field
|
|
39
|
+
# — Store/ProductStore's own comment says those are always live,
|
|
40
|
+
# and a fetched file is one more untrusted input than a locally
|
|
41
|
+
# built one, so this is checked again on the way in rather than
|
|
42
|
+
# trusting the source repo's own review (there's no CI gate for
|
|
43
|
+
# it — Decision 1a, "no central artifact, so no signing").
|
|
44
|
+
FORBIDDEN_FIELDS = %w[price amount stock].freeze
|
|
45
|
+
|
|
46
|
+
def initialize(stores_path: STORES_PATH, products_path: PRODUCTS_PATH,
|
|
47
|
+
stores_url: KnownStoresUrl::STORES, products_url: KnownStoresUrl::PRODUCTS)
|
|
48
|
+
@stores_path = stores_path
|
|
49
|
+
@products_path = products_path
|
|
50
|
+
@stores_url = stores_url
|
|
51
|
+
@products_url = products_url
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Hash] origin => entry, same schema as Index::Store's own
|
|
55
|
+
# file.
|
|
56
|
+
def stores = read(@stores_path)
|
|
57
|
+
|
|
58
|
+
# @return [Hash] key => entry, same schema as Index::ProductStore's
|
|
59
|
+
# own file.
|
|
60
|
+
def products = read(@products_path)
|
|
61
|
+
|
|
62
|
+
def exists? = File.exist?(@stores_path) || File.exist?(@products_path)
|
|
63
|
+
|
|
64
|
+
# @return [Integer, nil] seconds since the stores cache was last
|
|
65
|
+
# fetched — its own file mtime, since the cache is written in the
|
|
66
|
+
# same schema as the local index and carries no fetch timestamp
|
|
67
|
+
# of its own. nil when there's no cache yet.
|
|
68
|
+
def age(now: Time.now)
|
|
69
|
+
return nil unless File.exist?(@stores_path)
|
|
70
|
+
|
|
71
|
+
now.to_i - File.mtime(@stores_path).to_i
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def stale?(now: Time.now)
|
|
75
|
+
a = age(now: now)
|
|
76
|
+
a.nil? || a > STALE_AFTER
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The "first run" trigger: only fetches when there's no cache file
|
|
80
|
+
# at all — a stale-but-present cache is left for `index
|
|
81
|
+
# refresh`/`doctor` to decide about, not silently re-fetched on
|
|
82
|
+
# every `find`.
|
|
83
|
+
# @return [Boolean] whether a fetch happened (and its outcome).
|
|
84
|
+
def fetch_if_missing!
|
|
85
|
+
return false if exists?
|
|
86
|
+
|
|
87
|
+
refresh!
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# `index refresh` and a stale `doctor` check call this
|
|
91
|
+
# unconditionally.
|
|
92
|
+
# @return [Boolean] true when at least one of the two files
|
|
93
|
+
# fetched successfully.
|
|
94
|
+
def refresh!
|
|
95
|
+
stores_ok = fetch_and_cache(@stores_url, @stores_path)
|
|
96
|
+
products_ok = fetch_and_cache(@products_url, @products_path)
|
|
97
|
+
stores_ok || products_ok
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def fetch_and_cache(url, path)
|
|
103
|
+
data = Timeout.timeout(TIMEOUT) { fetch(url) }
|
|
104
|
+
return false unless valid?(data)
|
|
105
|
+
|
|
106
|
+
write(path, sanitize(data))
|
|
107
|
+
true
|
|
108
|
+
rescue StandardError
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def fetch(url)
|
|
113
|
+
uri = URI.parse(url)
|
|
114
|
+
response = Portage::Ucp::Support::Connection.start(
|
|
115
|
+
uri, route: :search, open_timeout: TIMEOUT, read_timeout: TIMEOUT
|
|
116
|
+
) { |http| http.get(uri.request_uri, UserAgent.headers) }
|
|
117
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
118
|
+
|
|
119
|
+
JSON.parse(response.body)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Same shape as Store/ProductStore's own file: a Hash keyed by
|
|
123
|
+
# origin (stores) or product key (products), each value itself a
|
|
124
|
+
# Hash. Anything else — an array, a string, a top-level list — is a
|
|
125
|
+
# fetch gone wrong, not a cache worth keeping.
|
|
126
|
+
def valid?(data)
|
|
127
|
+
data.is_a?(Hash) && data.values.all?(Hash)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def sanitize(data)
|
|
131
|
+
data.transform_values { |entry| entry.reject { |k, _| FORBIDDEN_FIELDS.include?(k.to_s) } }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# A cache that can't be written just means this fetch's findings
|
|
135
|
+
# aren't saved — never a crash (same posture as Store/
|
|
136
|
+
# ProductStore#write).
|
|
137
|
+
def write(path, data)
|
|
138
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
139
|
+
File.write(path, JSON.generate(data))
|
|
140
|
+
rescue StandardError
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def read(path)
|
|
145
|
+
return {} unless File.readable?(path)
|
|
146
|
+
|
|
147
|
+
parsed = JSON.parse(File.read(path, encoding: "UTF-8"))
|
|
148
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
149
|
+
rescue StandardError
|
|
150
|
+
{}
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
module Index
|
|
7
|
+
# `~/.portage/index/products.json` — product identities the index has
|
|
8
|
+
# seen, one entry per GTIN/MPN (when a source gives one) or per
|
|
9
|
+
# title+brand otherwise. **No prices or stock** — those are always
|
|
10
|
+
# live, read straight from the store's own catalog at buy time
|
|
11
|
+
# (docs/plans/buy-skill-and-local-browser.md Phase 2b).
|
|
12
|
+
#
|
|
13
|
+
# Entry shape: title, brand, gtin, category, aliases (other titles the
|
|
14
|
+
# same product went by at a different store), stores (array of
|
|
15
|
+
# {origin:, last_seen:}), sources (which index sources ever saw it —
|
|
16
|
+
# Phase 3 adds this so Index::Exporter can tell a product only a
|
|
17
|
+
# browser import saw from one a real source found; entries written
|
|
18
|
+
# before it have none).
|
|
19
|
+
class ProductStore
|
|
20
|
+
PATH = File.join(Dir.home, ".portage", "index", "products.json").freeze
|
|
21
|
+
|
|
22
|
+
def initialize(path: PATH)
|
|
23
|
+
@path = path
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def all = entries.values
|
|
27
|
+
|
|
28
|
+
def find(key) = entries[key]
|
|
29
|
+
|
|
30
|
+
# @param key [String] a stable key for this product — the source's
|
|
31
|
+
# GTIN/MPN when it has one, else a normalized title+brand.
|
|
32
|
+
# @param origin [String] the store this sighting came from.
|
|
33
|
+
# @param seen_at [Integer] unix seconds.
|
|
34
|
+
def upsert(key, origin:, seen_at:, **fields)
|
|
35
|
+
existing = entries[key] || { "key" => key, "aliases" => [], "stores" => [] }
|
|
36
|
+
aliases = merge_aliases(existing, fields[:title])
|
|
37
|
+
merged = existing.merge(fields.transform_keys(&:to_s)) { |field, old, new| merge_field(field, old, new) }
|
|
38
|
+
merged["aliases"] = aliases
|
|
39
|
+
merged["stores"] = merge_stores(merged["stores"], origin, seen_at)
|
|
40
|
+
entries[key] = merged
|
|
41
|
+
write
|
|
42
|
+
merged
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def exists? = File.exist?(@path)
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# `aliases`/`stores` accumulate across upserts (merged separately
|
|
50
|
+
# below), `sources` is the union of every sighting's; every other
|
|
51
|
+
# field (title, brand, gtin, category) is just the latest
|
|
52
|
+
# sighting's value, since a later source's read is no less
|
|
53
|
+
# authoritative than an earlier one's.
|
|
54
|
+
def merge_field(field, old, new)
|
|
55
|
+
return (Array(old) + Array(new)).uniq if field == "sources"
|
|
56
|
+
|
|
57
|
+
%w[aliases stores].include?(field) ? old : (new || old)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The *previous* title becomes an alias when a later sighting gives
|
|
61
|
+
# a different one — the newest sighting's title stays canonical
|
|
62
|
+
# (see #merge_field), and the one it replaced is kept so a search
|
|
63
|
+
# for either still finds this product.
|
|
64
|
+
def merge_aliases(existing, new_title)
|
|
65
|
+
old_title = existing["title"]
|
|
66
|
+
return Array(existing["aliases"]) if new_title.nil? || old_title.nil? || new_title == old_title
|
|
67
|
+
|
|
68
|
+
(Array(existing["aliases"]) + [old_title]).uniq
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def merge_stores(stores, origin, seen_at)
|
|
72
|
+
kept = Array(stores).reject { |s| s["origin"] == origin }
|
|
73
|
+
(kept + [{ "origin" => origin, "last_seen" => seen_at }]).sort_by { |s| s["origin"] }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def entries
|
|
77
|
+
@entries ||= read
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def read
|
|
81
|
+
return {} unless File.readable?(@path)
|
|
82
|
+
|
|
83
|
+
# See Index::Store#read's comment — a product title routinely
|
|
84
|
+
# carries non-ASCII bytes, so this has to read as UTF-8 rather
|
|
85
|
+
# than whatever the process's default external encoding is.
|
|
86
|
+
parsed = JSON.parse(File.read(@path, encoding: "UTF-8"))
|
|
87
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
88
|
+
rescue StandardError
|
|
89
|
+
{}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def write
|
|
93
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
94
|
+
File.write(@path, JSON.generate(@entries))
|
|
95
|
+
rescue StandardError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|