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,30 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
# Where `find`/`buy`/`OfferSources::ShopifyCatalog` get
|
|
4
|
+
# `meta.ucp-agent.profile` when the caller hasn't set
|
|
5
|
+
# PORTAGE_AGENT_PROFILE.
|
|
6
|
+
#
|
|
7
|
+
# Real UCP servers fetch and verify that URL before answering any call
|
|
8
|
+
# (Transports::Http), and the CLI used to read it via a bare
|
|
9
|
+
# `ENV.fetch("PORTAGE_AGENT_PROFILE", nil)` with no fallback — a fresh
|
|
10
|
+
# install that never copied `.env.example` got a bare
|
|
11
|
+
# MissingAgentProfileError on its very first `find`/`buy`. The repo
|
|
12
|
+
# already publishes its own profile document
|
|
13
|
+
# (`portage-cli/agent-profile/agent-profile.json`) over jsdelivr, and
|
|
14
|
+
# that URL is confirmed live against catalog.shopify.com (see
|
|
15
|
+
# docs/agent-profile.md), so it's a reasonable "works enough to try it"
|
|
16
|
+
# default — not a substitute for a caller hosting their own via
|
|
17
|
+
# `portage generate agent-profile`.
|
|
18
|
+
module AgentProfileUrl
|
|
19
|
+
DEFAULT = "https://cdn.jsdelivr.net/gh/tomtom87/Portage@main/portage-cli/agent-profile/agent-profile.json"
|
|
20
|
+
.freeze
|
|
21
|
+
|
|
22
|
+
# @return [String] PORTAGE_AGENT_PROFILE, or DEFAULT when it's unset
|
|
23
|
+
# or blank.
|
|
24
|
+
def self.resolve
|
|
25
|
+
value = ENV.fetch("PORTAGE_AGENT_PROFILE", nil).to_s.strip
|
|
26
|
+
value.empty? ? DEFAULT : value
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
require "portage/ucp"
|
|
2
|
+
require_relative "human_prompt"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
# `portage policy set --require-approval person|any|off`
|
|
7
|
+
# (docs/plans/human-pick-and-approve.md Phase 2, Design § 5): how much
|
|
8
|
+
# approval a real `portage buy --yes` needs before it may charge or hand
|
|
9
|
+
# off.
|
|
10
|
+
#
|
|
11
|
+
# - `off`: `--yes` alone buys (the behaviour before this setting).
|
|
12
|
+
# - `any` (the default): `--yes` needs `--quote` with an approved quote,
|
|
13
|
+
# approved by the person at a tty or relayed by an agent.
|
|
14
|
+
# - `person`: only a quote the person approved at the tty counts — a
|
|
15
|
+
# model with a shell can't type on `/dev/tty`.
|
|
16
|
+
#
|
|
17
|
+
# Stored as `require_approval` in ~/.portage/policy.json (Portage::Ucp::
|
|
18
|
+
# Policy), next to the caps and allowlist it sits beside in `portage
|
|
19
|
+
# policy`. Policy#set takes any key and its file format is documented as
|
|
20
|
+
# private, and PolicyGuard reads only the keys it knows, so this needs
|
|
21
|
+
# no change to the portage-ucp gem. Deliberately no env var or
|
|
22
|
+
# config.json override: either would let an agent lower it without the
|
|
23
|
+
# tty confirmation `policy set` asks for. The gate itself is CLI-only
|
|
24
|
+
# (Cli.approval_gate); Buy's library callers aren't governed by it.
|
|
25
|
+
module ApprovalPolicy
|
|
26
|
+
KEY = "require_approval".freeze
|
|
27
|
+
LEVELS = %w[off any person].freeze
|
|
28
|
+
DEFAULT = "any".freeze
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# An unrecognised stored value (a hand-edited file) fails closed, to
|
|
33
|
+
# the strictest level.
|
|
34
|
+
# @return [String] one of LEVELS.
|
|
35
|
+
def level(policy = Portage::Ucp::Policy.load)
|
|
36
|
+
stored = policy.to_h[KEY]
|
|
37
|
+
return DEFAULT if stored.nil?
|
|
38
|
+
|
|
39
|
+
LEVELS.include?(stored) ? stored : "person"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def configured?(policy) = policy.to_h.key?(KEY)
|
|
43
|
+
|
|
44
|
+
def lowering?(from, to) = LEVELS.index(to) < LEVELS.index(from)
|
|
45
|
+
|
|
46
|
+
# @param quote [Hash, nil] a saved quote (Quotes).
|
|
47
|
+
def satisfied?(quote, level)
|
|
48
|
+
case level
|
|
49
|
+
when "off" then true
|
|
50
|
+
when "any" then [HumanPrompt::BY_PERSON, HumanPrompt::BY_AGENT].include?(quote&.dig("approved_by"))
|
|
51
|
+
else quote&.dig("approved_by") == HumanPrompt::BY_PERSON
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
require_relative "history"
|
|
2
|
+
require_relative "quotes"
|
|
3
|
+
require_relative "human_prompt"
|
|
4
|
+
require_relative "product_page"
|
|
5
|
+
require_relative "money"
|
|
6
|
+
require_relative "approval_policy"
|
|
7
|
+
|
|
8
|
+
module Portage
|
|
9
|
+
module Cli
|
|
10
|
+
# `portage approve QUOTE_ID` — loop step 5, the person says yes to the
|
|
11
|
+
# exact dry-run total (docs/plans/human-pick-and-approve.md Phase 2,
|
|
12
|
+
# Design § 5). Shows title, store, qty and total and asks yes/no.
|
|
13
|
+
#
|
|
14
|
+
# A yes typed on the `tty` surface marks the quote `approved_by:
|
|
15
|
+
# "person"`; `--relayed-yes` marks it `"agent_relayed"`. The `agent`
|
|
16
|
+
# surface asks nobody and returns `needs_approval` with the summary.
|
|
17
|
+
# `--view` only opens the product page. Whether an approval is enough
|
|
18
|
+
# for `buy --quote QUOTE_ID --yes` is ApprovalPolicy's call, at buy time
|
|
19
|
+
# — except under `person`, where a relayed yes could never be enough, so
|
|
20
|
+
# it isn't recorded and the agent is told to hand the question to the
|
|
21
|
+
# person's own terminal instead.
|
|
22
|
+
#
|
|
23
|
+
# Returns a report hash; Cli.run_approve prints it.
|
|
24
|
+
class Approve
|
|
25
|
+
def initialize(quote_id:, prompt:, relayed_yes: false, view: false, quotes: Quotes.new, history: History.new,
|
|
26
|
+
level: ApprovalPolicy.level)
|
|
27
|
+
@quote_id = quote_id
|
|
28
|
+
@prompt = prompt
|
|
29
|
+
@relayed_yes = relayed_yes
|
|
30
|
+
@view = view
|
|
31
|
+
@quotes = quotes
|
|
32
|
+
@history = history
|
|
33
|
+
@level = level
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call
|
|
37
|
+
quote = @quotes.find(@quote_id)
|
|
38
|
+
return unusable(quote) if quote.nil? || quote["used_at"]
|
|
39
|
+
|
|
40
|
+
summary = self.class.summary(quote, history: @history)
|
|
41
|
+
return view(summary) if @view
|
|
42
|
+
return relay(summary) if @relayed_yes
|
|
43
|
+
return self.class.needs_approval(summary, level: @level) unless @prompt.tty?
|
|
44
|
+
|
|
45
|
+
ask(summary)
|
|
46
|
+
rescue HumanPrompt::NoTerminal => e
|
|
47
|
+
{ outcome: "no_terminal", quote_id: @quote_id, message: e.message }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# What the person is asked to approve. Title and url were saved on
|
|
51
|
+
# the quote at dry-run time; a quote saved without them falls back to
|
|
52
|
+
# its offer's (via `offer_ref`, History#offer).
|
|
53
|
+
def self.summary(quote, history: History.new)
|
|
54
|
+
offer = fallback_offer(quote, history)
|
|
55
|
+
{ quote_id: quote["quote_id"], title: quote["title"] || offer&.dig("title"), store: quote["store"],
|
|
56
|
+
product_id: quote["product_id"], qty: quote["qty"], total: quote["total"], currency: quote["currency"],
|
|
57
|
+
total_display: quote["total"] ? Money.format_amount(quote["total"], quote["currency"]) : "unknown",
|
|
58
|
+
url: quote["url"] || offer&.dig("url"), approved_by: quote["approved_by"] }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.fallback_offer(quote, history)
|
|
62
|
+
return nil unless quote["offer_ref"] && (quote["title"].nil? || quote["url"].nil?)
|
|
63
|
+
|
|
64
|
+
history.offer(quote["offer_ref"])
|
|
65
|
+
end
|
|
66
|
+
private_class_method :fallback_offer
|
|
67
|
+
|
|
68
|
+
# Also what a refused `buy --yes` returns (Cli.approval_gate).
|
|
69
|
+
def self.needs_approval(summary, message: nil, level: ApprovalPolicy::DEFAULT)
|
|
70
|
+
{ outcome: "needs_approval", quote_id: summary[:quote_id], summary: summary,
|
|
71
|
+
message: message || relay_message(summary, level) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.relay_message(summary, level)
|
|
75
|
+
id = summary[:quote_id]
|
|
76
|
+
ask = if level == "person"
|
|
77
|
+
"Ask the person to run `portage approve #{id}` in their own terminal (require_approval: person " \
|
|
78
|
+
"doesn't accept a relayed yes)"
|
|
79
|
+
else
|
|
80
|
+
"Ask the person to approve #{describe(summary)} (show the url as a link), then relay a yes: " \
|
|
81
|
+
"`portage approve #{id} --relayed-yes`"
|
|
82
|
+
end
|
|
83
|
+
"#{ask}, then `portage buy --quote #{id} --yes`."
|
|
84
|
+
end
|
|
85
|
+
private_class_method :relay_message
|
|
86
|
+
|
|
87
|
+
def self.describe(summary)
|
|
88
|
+
"#{summary[:qty]} × #{summary[:title] || summary[:product_id]} from #{summary[:store]} " \
|
|
89
|
+
"for #{summary[:total_display]}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def ask(summary)
|
|
95
|
+
@prompt.say("#{summary[:title] || summary[:product_id]} — #{summary[:store]}")
|
|
96
|
+
@prompt.say(" qty #{summary[:qty]}, total #{summary[:total_display]}#{" — #{summary[:url]}" if summary[:url]}")
|
|
97
|
+
yes = @prompt.confirm("Buy #{self.class.describe(summary)}?", view: -> { page(summary)[:message] })
|
|
98
|
+
return approve(summary, HumanPrompt::BY_PERSON) if yes
|
|
99
|
+
|
|
100
|
+
{ outcome: "cancelled", quote_id: summary[:quote_id], message: "Not approved — nothing will be bought." }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def relay(summary)
|
|
104
|
+
return self.class.needs_approval(summary, level: @level) if @level == "person"
|
|
105
|
+
|
|
106
|
+
approve(summary, HumanPrompt::BY_AGENT)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def approve(summary, by)
|
|
110
|
+
quote = @quotes.approve(summary[:quote_id], by: by)
|
|
111
|
+
return { outcome: "error", quote_id: @quote_id, message: "Couldn't save the approval." } unless quote
|
|
112
|
+
|
|
113
|
+
{ outcome: "approved", quote_id: summary[:quote_id], approved_by: quote["approved_by"],
|
|
114
|
+
summary: summary.merge(approved_by: quote["approved_by"]),
|
|
115
|
+
message: "Approved #{self.class.describe(summary)} — next: " \
|
|
116
|
+
"`portage buy --quote #{summary[:quote_id]} --yes`." }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def view(summary) = page(summary).merge(quote_id: summary[:quote_id])
|
|
120
|
+
|
|
121
|
+
def page(summary) = ProductPage.new(url: summary[:url], store: summary[:store]).open
|
|
122
|
+
|
|
123
|
+
# Same outcomes `buy --quote` reports for the same two cases.
|
|
124
|
+
def unusable(quote)
|
|
125
|
+
if quote
|
|
126
|
+
{ outcome: "quote_used", quote_id: @quote_id,
|
|
127
|
+
message: "Quote #{@quote_id} has already been used — dry-run again for a new one." }
|
|
128
|
+
else
|
|
129
|
+
{ outcome: "quote_not_found", quote_id: @quote_id,
|
|
130
|
+
message: "No saved quote #{@quote_id} — run `portage buy ... --dry-run --json` for a new one." }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require_relative "../classifier"
|
|
2
|
+
require_relative "../index/builder"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
module BrowserImport
|
|
7
|
+
# "Smarter import" (docs/plans/buy-skill-and-local-browser.md Phase
|
|
8
|
+
# 3): a kept domain's categories come from what the user actually
|
|
9
|
+
# looked at there — each row's page title, bookmark folder name and
|
|
10
|
+
# URL slug (`/products/<slug>`, `/collections/<slug>`, `/c/<slug>`,
|
|
11
|
+
# `/category/<slug>`, split on `-`/`_`), run through the same
|
|
12
|
+
# Classifier every other index source uses, and weighted by visit
|
|
13
|
+
# count. A domain none of whose rows matches a category stays
|
|
14
|
+
# uncategorised: SearchBackends::Index then routes it by name only,
|
|
15
|
+
# never for a generic query.
|
|
16
|
+
module Categorize
|
|
17
|
+
TOP_CATEGORIES = Index::Builder::TOP_CATEGORIES
|
|
18
|
+
|
|
19
|
+
# A product page, for `--include-product-pages`: `/product/<x>` or
|
|
20
|
+
# `/products/<x>` (Shopify, WooCommerce and most others).
|
|
21
|
+
PRODUCT_PATH = %r{/products?/[^/?#]+}
|
|
22
|
+
|
|
23
|
+
# @param rows [Array<Hash>] Readers rows, all for one domain.
|
|
24
|
+
# @return [Hash{String => Integer}] category id => weight, top 5.
|
|
25
|
+
def self.domain(rows)
|
|
26
|
+
texts = Hash.new(0)
|
|
27
|
+
rows.each { |row| texts[text_of(row)] += row[:visits] }
|
|
28
|
+
tally = Hash.new(0)
|
|
29
|
+
texts.each do |text, visits|
|
|
30
|
+
Classifier.categories_for(text).each { |id| tally[id] += visits } unless text.empty?
|
|
31
|
+
end
|
|
32
|
+
tally.sort_by { |_id, weight| -weight }.first(TOP_CATEGORIES).to_h
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param kept [Array<Hash>] Importer's kept entries, rows included.
|
|
36
|
+
# @return [Array<Hash>] one product entry per distinct product-page
|
|
37
|
+
# title — key, title, url, origin, source ("history"/"bookmark"),
|
|
38
|
+
# category.
|
|
39
|
+
def self.products(kept)
|
|
40
|
+
kept.flat_map { |entry| products_of(entry) }.uniq { |p| p[:key] }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.products_of(entry)
|
|
44
|
+
entry[:rows].select { |r| r[:url].match?(PRODUCT_PATH) && !r[:title].empty? }.map do |row|
|
|
45
|
+
{ key: "title:#{row[:title].downcase.gsub(/[^a-z0-9]+/, '-')}", title: row[:title], url: row[:url],
|
|
46
|
+
origin: entry[:origin], source: row[:kind], category: Classifier.categories_for(text_of(row)).first }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
private_class_method :products_of
|
|
50
|
+
|
|
51
|
+
def self.text_of(row)
|
|
52
|
+
slugs = Classifier::SLUG_PATTERNS.filter_map { |pattern| pattern.match(row[:url])&.[](1) }
|
|
53
|
+
[row[:title], row[:folder], *slugs.map { |s| s.tr("-_", " ") }].reject(&:empty?).join(" ")
|
|
54
|
+
end
|
|
55
|
+
private_class_method :text_of
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
module BrowserImport
|
|
4
|
+
# The "shows the list and asks before writing" gate (docs/plans/
|
|
5
|
+
# buy-skill-and-local-browser.md Phase 3), shaped like
|
|
6
|
+
# WebmcpMappingConfirm: an explicit `--yes` is the only way to save
|
|
7
|
+
# without a prompt; a prompt needs a real TTY and no `--json`; and
|
|
8
|
+
# anything else — piped, CI, an agent running `--json` — saves
|
|
9
|
+
# nothing and says how to confirm. A dry run never saves, `--yes` or
|
|
10
|
+
# not.
|
|
11
|
+
class Confirm
|
|
12
|
+
# @param interactive [Boolean] false under --json or with no TTY on
|
|
13
|
+
# stdin (the caller decides).
|
|
14
|
+
def initialize(interactive:, input: $stdin, output: $stdout)
|
|
15
|
+
@interactive = interactive
|
|
16
|
+
@input = input
|
|
17
|
+
@output = output
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Symbol] :save, :declined, :dry_run, :nothing, or
|
|
21
|
+
# :needs_confirmation (no TTY/--json and no --yes).
|
|
22
|
+
def call(plan, yes:, dry_run:)
|
|
23
|
+
return :dry_run if dry_run
|
|
24
|
+
return :nothing if Array(plan[:kept]).empty?
|
|
25
|
+
return :save if yes
|
|
26
|
+
return :needs_confirmation unless @interactive
|
|
27
|
+
|
|
28
|
+
@output.print "Save these #{plan[:kept].length} store(s) to your local index? [y/N] "
|
|
29
|
+
@output.flush
|
|
30
|
+
@input.gets.to_s.strip.downcase == "y" ? :save : :declined
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
module BrowserImport
|
|
6
|
+
# Reduces Readers rows to domains — the only thing about a row an
|
|
7
|
+
# import keeps past this point, besides the page title/folder/slug
|
|
8
|
+
# words Categorize reads locally. `www.` is folded into the bare
|
|
9
|
+
# domain, and anything that isn't an http(s) URL with a host
|
|
10
|
+
# (`chrome://`, `file:`, `about:`, `javascript:`) is dropped.
|
|
11
|
+
module Domains
|
|
12
|
+
# @return [Hash{String => Hash}] domain => { hosts: {host => visits},
|
|
13
|
+
# rows: [...], visits: total }.
|
|
14
|
+
def self.group(rows)
|
|
15
|
+
groups = {}
|
|
16
|
+
rows.each do |row|
|
|
17
|
+
host = host_of(row[:url])
|
|
18
|
+
next unless host
|
|
19
|
+
|
|
20
|
+
group = (groups[host.delete_prefix("www.")] ||= { hosts: Hash.new(0), rows: [], visits: 0 })
|
|
21
|
+
group[:hosts][host] += row[:visits]
|
|
22
|
+
group[:rows] << row
|
|
23
|
+
group[:visits] += row[:visits]
|
|
24
|
+
end
|
|
25
|
+
groups
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The most-visited host variant (www. or not) as an https origin —
|
|
29
|
+
# UCP is https-only, so an http-only visit still probes https.
|
|
30
|
+
def self.origin_for(group) = "https://#{group[:hosts].max_by { |_h, visits| visits }.first}"
|
|
31
|
+
|
|
32
|
+
# An index entry's origin, reduced the same way, so it compares
|
|
33
|
+
# against a group's key.
|
|
34
|
+
def self.key_of(origin) = host_of(origin)&.delete_prefix("www.")
|
|
35
|
+
|
|
36
|
+
def self.host_of(url)
|
|
37
|
+
uri = URI.parse(url.to_s)
|
|
38
|
+
return nil unless %w[http https].include?(uri.scheme) && uri.host && !uri.host.empty?
|
|
39
|
+
|
|
40
|
+
uri.host.downcase
|
|
41
|
+
rescue URI::InvalidURIError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require "ipaddr"
|
|
2
|
+
|
|
3
|
+
require_relative "../search_backends"
|
|
4
|
+
|
|
5
|
+
module Portage
|
|
6
|
+
module Cli
|
|
7
|
+
module BrowserImport
|
|
8
|
+
# "Is this host obviously not a shop?" — decided locally, before a
|
|
9
|
+
# single probe is spent (docs/plans/buy-skill-and-local-browser.md
|
|
10
|
+
# Phase 3: "skipping obvious non-shops (NON_STORE_HOSTS, webmail,
|
|
11
|
+
# banks, intranet and localhost hosts)"). A skipped host never leaves
|
|
12
|
+
# the machine at all, so these lists err on the side of skipping:
|
|
13
|
+
# a missed shop costs one `portage index add`, a probed bank costs
|
|
14
|
+
# the user's trust.
|
|
15
|
+
module Filter
|
|
16
|
+
WEBMAIL_HOSTS = %w[
|
|
17
|
+
mail.google.com outlook.live.com outlook.office.com outlook.office365.com mail.yahoo.com
|
|
18
|
+
proton.me protonmail.com icloud.com fastmail.com mail.aol.com gmx.com zoho.com
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
# Banks and payment/finance hosts are never a UCP storefront, and
|
|
22
|
+
# are exactly the hosts a shopper least wants anything sent about.
|
|
23
|
+
# Not exhaustive — backed up by BANK_WORD for the long tail.
|
|
24
|
+
BANK_HOSTS = %w[
|
|
25
|
+
paypal.com wise.com revolut.com monzo.com starlingbank.com chase.com wellsfargo.com
|
|
26
|
+
citi.com capitalone.com americanexpress.com discover.com schwab.com fidelity.com vanguard.com
|
|
27
|
+
hsbc.com hsbc.co.uk barclays.co.uk natwest.com santander.co.uk nationwide.co.uk halifax.co.uk
|
|
28
|
+
coinbase.com kraken.com stripe.com klarna.com
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
BANK_WORD = /bank|creditunion/
|
|
32
|
+
|
|
33
|
+
# Tools, social and media sites a shopper visits all day that will
|
|
34
|
+
# never answer /.well-known/ucp — skipping them saves probes for
|
|
35
|
+
# the domains that might.
|
|
36
|
+
NON_SHOP_HOSTS = %w[
|
|
37
|
+
github.com gitlab.com bitbucket.org stackoverflow.com stackexchange.com linkedin.com slack.com
|
|
38
|
+
zoom.us notion.so atlassian.net figma.com claude.ai anthropic.com openai.com chatgpt.com
|
|
39
|
+
instagram.com tiktok.com netflix.com spotify.com twitch.tv discord.com whatsapp.com
|
|
40
|
+
medium.com substack.com apple.com microsoft.com live.com office.com gstatic.com
|
|
41
|
+
googleusercontent.com cloudflare.com amazonaws.com
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
LOCAL_SUFFIXES = %w[localhost local internal lan corp home.arpa test intranet].freeze
|
|
45
|
+
|
|
46
|
+
# @return [Symbol, nil] why `host` is skipped (:non_store, :webmail,
|
|
47
|
+
# :bank, :local), or nil when it's worth a probe.
|
|
48
|
+
def self.skip_reason(host)
|
|
49
|
+
host = host.to_s.downcase
|
|
50
|
+
return :local if local?(host)
|
|
51
|
+
return :webmail if webmail?(host)
|
|
52
|
+
return :bank if bank?(host)
|
|
53
|
+
return :non_store if non_store?(host)
|
|
54
|
+
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.local?(host)
|
|
59
|
+
return true if host.empty? || !host.include?(".")
|
|
60
|
+
return true if ip?(host)
|
|
61
|
+
|
|
62
|
+
LOCAL_SUFFIXES.any? { |suffix| host == suffix || host.end_with?(".#{suffix}") }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.webmail?(host)
|
|
66
|
+
host.start_with?("mail.", "webmail.") || matches?(host, WEBMAIL_HOSTS)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.bank?(host)
|
|
70
|
+
matches?(host, BANK_HOSTS) || host.match?(BANK_WORD)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# SearchBackends::NON_STORE_HOSTS (search engines, Wikipedia,
|
|
74
|
+
# social) plus this module's own NON_SHOP_HOSTS.
|
|
75
|
+
def self.non_store?(host)
|
|
76
|
+
!SearchBackends.store_candidate?("https://#{host}/") || matches?(host, NON_SHOP_HOSTS)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.matches?(host, list) = list.any? { |bad| host == bad || host.end_with?(".#{bad}") }
|
|
80
|
+
|
|
81
|
+
def self.ip?(host)
|
|
82
|
+
IPAddr.new(host.delete_prefix("[").delete_suffix("]"))
|
|
83
|
+
true
|
|
84
|
+
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
private_class_method :local?, :webmail?, :bank?, :non_store?, :matches?, :ip?
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|