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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +616 -0
  3. data/README.md +302 -5
  4. data/known-stores/categories.yml +1263 -0
  5. data/lib/portage/cli/agent_profile_url.rb +30 -0
  6. data/lib/portage/cli/approval_policy.rb +56 -0
  7. data/lib/portage/cli/approve.rb +135 -0
  8. data/lib/portage/cli/browser_import/categorize.rb +59 -0
  9. data/lib/portage/cli/browser_import/confirm.rb +35 -0
  10. data/lib/portage/cli/browser_import/domains.rb +47 -0
  11. data/lib/portage/cli/browser_import/filter.rb +91 -0
  12. data/lib/portage/cli/browser_import/importer.rb +248 -0
  13. data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
  14. data/lib/portage/cli/browser_import/prober.rb +60 -0
  15. data/lib/portage/cli/browser_import/profiles.rb +114 -0
  16. data/lib/portage/cli/browser_import/readers.rb +179 -0
  17. data/lib/portage/cli/browser_import/saver.rb +62 -0
  18. data/lib/portage/cli/browser_import/sqlite.rb +68 -0
  19. data/lib/portage/cli/browser_import.rb +23 -0
  20. data/lib/portage/cli/browser_opener.rb +38 -0
  21. data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
  22. data/lib/portage/cli/browser_profile/bridge.rb +120 -0
  23. data/lib/portage/cli/browser_profile/browsers.rb +69 -0
  24. data/lib/portage/cli/browser_profile/cdp.rb +67 -0
  25. data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
  26. data/lib/portage/cli/browser_profile/errors.rb +26 -0
  27. data/lib/portage/cli/browser_profile/launcher.rb +34 -0
  28. data/lib/portage/cli/browser_profile/profile.rb +93 -0
  29. data/lib/portage/cli/browser_profile.rb +25 -0
  30. data/lib/portage/cli/buy.rb +559 -29
  31. data/lib/portage/cli/checkout_handoff.rb +5 -24
  32. data/lib/portage/cli/classifier.rb +158 -0
  33. data/lib/portage/cli/compare.rb +3 -0
  34. data/lib/portage/cli/doctor.rb +155 -1
  35. data/lib/portage/cli/dot_env.rb +55 -0
  36. data/lib/portage/cli/find.rb +103 -12
  37. data/lib/portage/cli/handoff_agents.rb +186 -0
  38. data/lib/portage/cli/handoff_only.rb +94 -0
  39. data/lib/portage/cli/handoff_reconciler.rb +15 -1
  40. data/lib/portage/cli/handoff_target.rb +61 -0
  41. data/lib/portage/cli/history.rb +47 -2
  42. data/lib/portage/cli/human_prompt.rb +118 -0
  43. data/lib/portage/cli/index/builder.rb +335 -0
  44. data/lib/portage/cli/index/exporter.rb +91 -0
  45. data/lib/portage/cli/index/known_cache.rb +155 -0
  46. data/lib/portage/cli/index/product_store.rb +101 -0
  47. data/lib/portage/cli/index/sources/browser.rb +31 -0
  48. data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
  49. data/lib/portage/cli/index/sources/stores_file.rb +58 -0
  50. data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
  51. data/lib/portage/cli/index/sources/wikidata.rb +95 -0
  52. data/lib/portage/cli/index/sources.rb +44 -0
  53. data/lib/portage/cli/index/store.rb +109 -0
  54. data/lib/portage/cli/index.rb +20 -0
  55. data/lib/portage/cli/known_stores_url.rb +15 -0
  56. data/lib/portage/cli/money.rb +18 -0
  57. data/lib/portage/cli/offer_choice.rb +38 -0
  58. data/lib/portage/cli/offer_sources.rb +460 -0
  59. data/lib/portage/cli/payment_methods.rb +24 -3
  60. data/lib/portage/cli/pick.rb +167 -0
  61. data/lib/portage/cli/product_page.rb +84 -0
  62. data/lib/portage/cli/quotes.rb +82 -0
  63. data/lib/portage/cli/search_backends.rb +337 -12
  64. data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
  65. data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
  66. data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
  67. data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
  68. data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
  69. data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
  70. data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
  71. data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
  72. data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
  73. data/lib/portage/cli/setup_wizard.rb +74 -0
  74. data/lib/portage/cli/version.rb +1 -1
  75. data/lib/portage/cli/webmcp.rb +10 -3
  76. data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
  77. data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
  78. data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
  79. data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
  80. data/lib/portage/cli/webmcp_mappings.rb +84 -0
  81. data/lib/portage/cli.rb +930 -43
  82. 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