portage-cli 0.10.0 → 0.12.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 +129 -0
- data/README.md +107 -21
- data/known-stores/categories.yml +6841 -18
- data/known-stores/category-stoplist.yml +40 -0
- data/known-stores/category-synonyms.yml +15 -0
- data/lib/portage/cli/browser_import/categorize.rb +5 -2
- data/lib/portage/cli/buy.rb +217 -39
- data/lib/portage/cli/check.rb +11 -1
- data/lib/portage/cli/classifier/ranking.rb +135 -0
- data/lib/portage/cli/classifier/table.rb +63 -0
- data/lib/portage/cli/classifier.rb +23 -44
- data/lib/portage/cli/confidence_check.rb +27 -7
- data/lib/portage/cli/confidence_state.rb +109 -0
- data/lib/portage/cli/doctor.rb +16 -6
- data/lib/portage/cli/find.rb +69 -4
- data/lib/portage/cli/index/builder.rb +49 -12
- data/lib/portage/cli/index/database.rb +150 -0
- data/lib/portage/cli/index/entry_product.rb +37 -0
- data/lib/portage/cli/index/legacy_import.rb +54 -0
- data/lib/portage/cli/index/product_store.rb +73 -35
- data/lib/portage/cli/index/schema.rb +70 -0
- data/lib/portage/cli/index/search.rb +73 -0
- data/lib/portage/cli/index/sources/storefront_products/mapper.rb +127 -0
- data/lib/portage/cli/index/sources/storefront_products/pages.rb +114 -0
- data/lib/portage/cli/index/sources/storefront_products/robots.rb +70 -0
- data/lib/portage/cli/index/sources/storefront_products.rb +147 -0
- data/lib/portage/cli/index/sources.rb +5 -2
- data/lib/portage/cli/index/store.rb +23 -47
- data/lib/portage/cli/index.rb +1 -0
- data/lib/portage/cli/offer_sources.rb +17 -2
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +111 -12
- metadata +30 -2
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "sqlite3"
|
|
4
|
+
require_relative "schema"
|
|
5
|
+
require_relative "legacy_import"
|
|
6
|
+
|
|
7
|
+
module Portage
|
|
8
|
+
module Cli
|
|
9
|
+
module Index
|
|
10
|
+
# `~/.portage/index/index.sqlite3` — the one file behind Index::Store
|
|
11
|
+
# and Index::ProductStore (docs/plans/local-catalogue.md Phase 1).
|
|
12
|
+
# Plain SQL over the `sqlite3` gem, no ORM: every entry is stored as
|
|
13
|
+
# the same JSON object the old stores.json/products.json held, so the
|
|
14
|
+
# entry shapes documented on Store/ProductStore did not change.
|
|
15
|
+
#
|
|
16
|
+
# Posture: the file is created 0600 (and so are SQLite's -wal/-shm
|
|
17
|
+
# side files, which copy the main file's mode); it runs in WAL mode
|
|
18
|
+
# with a busy timeout; a failed write raises rather than being
|
|
19
|
+
# swallowed. Nothing is created until something is written — a read
|
|
20
|
+
# of an index that doesn't exist yet just comes back empty.
|
|
21
|
+
#
|
|
22
|
+
# Forward-only schema: MIGRATIONS[n] takes user_version n to n + 1,
|
|
23
|
+
# and a database stamped newer than this build knows is refused
|
|
24
|
+
# rather than guessed at.
|
|
25
|
+
#
|
|
26
|
+
# The open that creates the database also imports a legacy stores.json/
|
|
27
|
+
# products.json that sits beside it, in one transaction, then renames
|
|
28
|
+
# it to *.json.migrated. Only that one open does — later opens never
|
|
29
|
+
# look, even if the tables have since been emptied — and nothing is
|
|
30
|
+
# ever deleted.
|
|
31
|
+
class Database
|
|
32
|
+
class Error < StandardError; end
|
|
33
|
+
|
|
34
|
+
FILENAME = "index.sqlite3".freeze
|
|
35
|
+
BUSY_TIMEOUT_MS = 5_000
|
|
36
|
+
|
|
37
|
+
# table => its key column. The legacy json file for a table is
|
|
38
|
+
# "<table>.json".
|
|
39
|
+
TABLES = { "stores" => "origin", "products" => "key" }.freeze
|
|
40
|
+
|
|
41
|
+
# The database that lives beside a legacy stores.json/products.json.
|
|
42
|
+
def self.path_for(json_path) = File.join(File.dirname(json_path), FILENAME)
|
|
43
|
+
|
|
44
|
+
attr_reader :path
|
|
45
|
+
|
|
46
|
+
def initialize(path:)
|
|
47
|
+
@path = path
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# True once there is something to read: the database itself, or a
|
|
51
|
+
# legacy json file that the next open will import.
|
|
52
|
+
def exists? = File.exist?(@path) || LegacyImport.files(File.dirname(@path)).any?
|
|
53
|
+
|
|
54
|
+
# @return [Hash] key => parsed entry, in insertion order.
|
|
55
|
+
def entries(table)
|
|
56
|
+
return {} unless exists?
|
|
57
|
+
|
|
58
|
+
execute("SELECT #{TABLES.fetch(table)}, data FROM #{table} ORDER BY rowid").to_h { |k, d| [k, JSON.parse(d)] }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def get(table, key)
|
|
62
|
+
return nil unless exists?
|
|
63
|
+
|
|
64
|
+
row = execute("SELECT data FROM #{table} WHERE #{TABLES.fetch(table)} = ?", [key]).first
|
|
65
|
+
row && JSON.parse(row.first)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def put(table, key, entry)
|
|
69
|
+
column = TABLES.fetch(table)
|
|
70
|
+
execute("INSERT INTO #{table} (#{column}, data) VALUES (?, ?) " \
|
|
71
|
+
"ON CONFLICT(#{column}) DO UPDATE SET data = excluded.data", [key, JSON.generate(entry)])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def delete(table, key)
|
|
75
|
+
execute("DELETE FROM #{table} WHERE #{TABLES.fetch(table)} = ?", [key])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def count(table)
|
|
79
|
+
return 0 unless exists?
|
|
80
|
+
|
|
81
|
+
execute("SELECT COUNT(*) FROM #{table}").first.first
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Reentrant: a nested call joins the outer transaction. IMMEDIATE
|
|
85
|
+
# takes the write lock up front, so a read-modify-write inside it
|
|
86
|
+
# can't interleave with another process's.
|
|
87
|
+
def transaction(&)
|
|
88
|
+
return yield if connection.transaction_active?
|
|
89
|
+
|
|
90
|
+
connection.transaction(:immediate, &)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [Array<Array>] raw rows — for the specs and Phase 2's
|
|
94
|
+
# search; everything else goes through the entry methods above.
|
|
95
|
+
def execute(sql, binds = [])
|
|
96
|
+
connection.execute(sql, binds)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def pragma(name) = execute("PRAGMA #{name}").flatten.first
|
|
100
|
+
|
|
101
|
+
# What `portage doctor` reports.
|
|
102
|
+
def info
|
|
103
|
+
stores = count("stores")
|
|
104
|
+
products = count("products")
|
|
105
|
+
{ path: @path, exists: File.exist?(@path), stores: stores, products: products,
|
|
106
|
+
fts5: Schema.fts5_available? }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def connection
|
|
112
|
+
@connection ||= open_connection
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def open_connection
|
|
116
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
117
|
+
File.open(@path, File::CREAT | File::WRONLY, 0o600) { nil }
|
|
118
|
+
FileUtils.chmod(0o600, @path)
|
|
119
|
+
db = SQLite3::Database.new(@path)
|
|
120
|
+
db.busy_timeout = BUSY_TIMEOUT_MS
|
|
121
|
+
db.execute("PRAGMA journal_mode = WAL")
|
|
122
|
+
db.execute("PRAGMA synchronous = NORMAL")
|
|
123
|
+
@connection = db
|
|
124
|
+
# One transaction, so a failed import leaves the database unstamped
|
|
125
|
+
# and the next open tries again rather than skipping the import.
|
|
126
|
+
transaction { LegacyImport.new(self, File.dirname(@path)).call if migrate.zero? }
|
|
127
|
+
db
|
|
128
|
+
rescue StandardError
|
|
129
|
+
@connection&.close
|
|
130
|
+
@connection = nil
|
|
131
|
+
raise
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def migrate
|
|
135
|
+
version = @connection.get_first_value("PRAGMA user_version")
|
|
136
|
+
if version > Schema::MIGRATIONS.length
|
|
137
|
+
raise Error,
|
|
138
|
+
"#{@path} is schema v#{version}, newer than this portage understands"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
Schema::MIGRATIONS.drop(version).each_with_index do |step, i|
|
|
142
|
+
step.call(@connection)
|
|
143
|
+
@connection.execute("PRAGMA user_version = #{version + i + 1}")
|
|
144
|
+
end
|
|
145
|
+
version
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
require "portage/ucp"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
module Index
|
|
6
|
+
# A ProductStore entry as the persisted subset of a UCP Product wire
|
|
7
|
+
# hash (docs/plans/local-catalogue.md Phase 3), for `index search`'s
|
|
8
|
+
# `product` field. Built only from what the index keeps, so it has no
|
|
9
|
+
# id, description, price_range or availability: the index never stores
|
|
10
|
+
# a price or stock, and a missing field is left out rather than faked.
|
|
11
|
+
# The live Product comes from `find`.
|
|
12
|
+
module EntryProduct
|
|
13
|
+
TAXONOMY = "google_product_category".freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# @param entry [Hash] a ProductStore entry.
|
|
18
|
+
# @return [Hash] string-keyed, UCP Product shaped.
|
|
19
|
+
def wire(entry)
|
|
20
|
+
wire = { "title" => entry["title"], "handle" => entry["handle"], "url" => entry["url"],
|
|
21
|
+
"media" => media(entry["image_url"]), "options" => entry["options"],
|
|
22
|
+
"variants" => Array(entry["variant_ids"]).map { |id| { "id" => id } },
|
|
23
|
+
"categories" => category(entry["category"]) }
|
|
24
|
+
wire.reject { |_field, value| value.nil? || value == [] }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def media(url)
|
|
28
|
+
url.to_s.empty? ? nil : [Portage::Ucp::Media.new(type: "image", url: url).to_wire_h]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def category(id)
|
|
32
|
+
id.to_s.empty? ? nil : [Portage::Ucp::Category.new(value: id, taxonomy: TAXONOMY).to_wire_h]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
module Index
|
|
6
|
+
# Moves a pre-SQLite stores.json/products.json into an Index::Database
|
|
7
|
+
# (docs/plans/local-catalogue.md Phase 1): every object entry, in one
|
|
8
|
+
# transaction, then the file is renamed to *.json.migrated. Nothing
|
|
9
|
+
# is ever deleted, and an unreadable or non-object file is left
|
|
10
|
+
# untouched. Index::Database runs this only on the open that creates
|
|
11
|
+
# the database, so it happens at most once.
|
|
12
|
+
class LegacyImport
|
|
13
|
+
# @return [Array<String>] the legacy json files present in `dir`.
|
|
14
|
+
def self.files(dir)
|
|
15
|
+
Database::TABLES.keys.map { |table| File.join(dir, "#{table}.json") }.select { |file| File.file?(file) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(database, dir)
|
|
19
|
+
@database = database
|
|
20
|
+
@dir = dir
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
self.class.files(@dir).each do |file|
|
|
25
|
+
entries = read(file)
|
|
26
|
+
next unless entries
|
|
27
|
+
|
|
28
|
+
@database.transaction do
|
|
29
|
+
entries.each { |key, entry| @database.put(File.basename(file, ".json"), key, entry) if entry.is_a?(Hash) }
|
|
30
|
+
end
|
|
31
|
+
rename(file)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def read(file)
|
|
38
|
+
parsed = JSON.parse(File.read(file, encoding: "UTF-8"))
|
|
39
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
40
|
+
rescue JSON::ParserError, SystemCallError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The rows are already committed, so a rename that fails must not
|
|
45
|
+
# fail the open — the file is just left where it was.
|
|
46
|
+
def rename(file)
|
|
47
|
+
File.rename(file, "#{file}.migrated")
|
|
48
|
+
rescue SystemCallError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
require "json"
|
|
2
|
-
|
|
2
|
+
require_relative "database"
|
|
3
|
+
require_relative "search"
|
|
3
4
|
|
|
4
5
|
module Portage
|
|
5
6
|
module Cli
|
|
6
7
|
module Index
|
|
7
|
-
# `~/.portage/index/
|
|
8
|
+
# The `products` table of `~/.portage/index/index.sqlite3` (Index::Database;
|
|
9
|
+
# `products.json` before docs/plans/local-catalogue.md Phase 1) — product identities the index has
|
|
8
10
|
# seen, one entry per GTIN/MPN (when a source gives one) or per
|
|
9
11
|
# title+brand otherwise. **No prices or stock** — those are always
|
|
10
12
|
# live, read straight from the store's own catalog at buy time
|
|
@@ -19,33 +21,92 @@ module Portage
|
|
|
19
21
|
class ProductStore
|
|
20
22
|
PATH = File.join(Dir.home, ".portage", "index", "products.json").freeze
|
|
21
23
|
|
|
24
|
+
# @param path [String] where the legacy products.json lives (or
|
|
25
|
+
# would live) — the database sits beside it, and a products.json
|
|
26
|
+
# found there is imported on first open.
|
|
22
27
|
def initialize(path: PATH)
|
|
23
|
-
@
|
|
28
|
+
@db = Database.new(path: Database.path_for(path))
|
|
24
29
|
end
|
|
25
30
|
|
|
26
|
-
def all = entries.values
|
|
31
|
+
def all = @db.entries("products").values
|
|
27
32
|
|
|
28
|
-
def find(key) =
|
|
33
|
+
def find(key) = @db.get("products", key)
|
|
29
34
|
|
|
30
35
|
# @param key [String] a stable key for this product — the source's
|
|
31
36
|
# GTIN/MPN when it has one, else a normalized title+brand.
|
|
32
37
|
# @param origin [String] the store this sighting came from.
|
|
33
38
|
# @param seen_at [Integer] unix seconds.
|
|
34
39
|
def upsert(key, origin:, seen_at:, **fields)
|
|
35
|
-
|
|
40
|
+
@db.transaction { merge_and_write(key, origin, seen_at, fields) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# A batch of sightings in one transaction — all of them land or,
|
|
44
|
+
# if any raises, none do. Each row is a Hash: `key:`, `origin:`,
|
|
45
|
+
# `seen_at:` plus the same fields #upsert takes. Rows merge in
|
|
46
|
+
# order, so a key repeated in one batch accumulates like two
|
|
47
|
+
# #upsert calls would.
|
|
48
|
+
# @return [Array<Hash>] the merged entries, in row order.
|
|
49
|
+
def upsert_many(rows)
|
|
50
|
+
return [] if rows.empty?
|
|
51
|
+
|
|
52
|
+
@db.transaction do
|
|
53
|
+
rows.map do |row|
|
|
54
|
+
fields = row.except(:key, :origin, :seen_at)
|
|
55
|
+
merge_and_write(row.fetch(:key), row.fetch(:origin), row.fetch(:seen_at), fields)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def exists? = @db.exists?
|
|
61
|
+
|
|
62
|
+
def count = @db.count("products")
|
|
63
|
+
|
|
64
|
+
# `index show --products`: one page of entries, in insertion order.
|
|
65
|
+
def page(number, per_page:)
|
|
66
|
+
return [] unless exists?
|
|
67
|
+
|
|
68
|
+
offset = ([number.to_i, 1].max - 1) * per_page
|
|
69
|
+
rows = @db.execute("SELECT data FROM products ORDER BY id LIMIT ? OFFSET ?", [per_page, offset])
|
|
70
|
+
rows.map { |(data)| JSON.parse(data) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# `portage index search` (docs/plans/local-catalogue.md Phase 2):
|
|
74
|
+
# every query word must match title, brand, category or an alias
|
|
75
|
+
# (as a prefix, after dropping a plural ending), best bm25 first.
|
|
76
|
+
# Without FTS5 (a system SQLite built without it) the same filters
|
|
77
|
+
# run as a LIKE scan, unranked. Untrusted seeds, same as every
|
|
78
|
+
# other entry: no price, no stock.
|
|
79
|
+
# @param store [String, nil] a host or URL; matches that host's origin.
|
|
80
|
+
# @return [Array<Hash>] entries.
|
|
81
|
+
def search(query, category: nil, store: nil, limit: 20)
|
|
82
|
+
words = Search.words(query)
|
|
83
|
+
return [] if words.empty? || !exists?
|
|
84
|
+
|
|
85
|
+
sql, binds = Search.sql(words, category: category, host: Search.host_of(store), limit: limit,
|
|
86
|
+
fts: search_engine == "fts5")
|
|
87
|
+
@db.execute(sql, binds).map { |(data)| JSON.parse(data) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# "fts5", or "like" when the database has no products_fts table.
|
|
91
|
+
def search_engine
|
|
92
|
+
return Schema.fts5_available? ? "fts5" : "like" unless exists?
|
|
93
|
+
|
|
94
|
+
fts = @db.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'products_fts'")
|
|
95
|
+
fts.empty? ? "like" : "fts5"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def merge_and_write(key, origin, seen_at, fields)
|
|
101
|
+
existing = @db.get("products", key) || { "key" => key, "aliases" => [], "stores" => [] }
|
|
36
102
|
aliases = merge_aliases(existing, fields[:title])
|
|
37
103
|
merged = existing.merge(fields.transform_keys(&:to_s)) { |field, old, new| merge_field(field, old, new) }
|
|
38
104
|
merged["aliases"] = aliases
|
|
39
105
|
merged["stores"] = merge_stores(merged["stores"], origin, seen_at)
|
|
40
|
-
|
|
41
|
-
write
|
|
106
|
+
@db.put("products", key, merged)
|
|
42
107
|
merged
|
|
43
108
|
end
|
|
44
109
|
|
|
45
|
-
def exists? = File.exist?(@path)
|
|
46
|
-
|
|
47
|
-
private
|
|
48
|
-
|
|
49
110
|
# `aliases`/`stores` accumulate across upserts (merged separately
|
|
50
111
|
# below), `sources` is the union of every sighting's; every other
|
|
51
112
|
# field (title, brand, gtin, category) is just the latest
|
|
@@ -72,29 +133,6 @@ module Portage
|
|
|
72
133
|
kept = Array(stores).reject { |s| s["origin"] == origin }
|
|
73
134
|
(kept + [{ "origin" => origin, "last_seen" => seen_at }]).sort_by { |s| s["origin"] }
|
|
74
135
|
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
136
|
end
|
|
99
137
|
end
|
|
100
138
|
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require "sqlite3"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
module Index
|
|
6
|
+
# The SQL behind Index::Database, forward-only: MIGRATIONS[n] takes
|
|
7
|
+
# user_version n to n + 1.
|
|
8
|
+
module Schema
|
|
9
|
+
# Kept minimal: `data` is the entry's JSON verbatim. `products`
|
|
10
|
+
# carries an integer id (rather than the key alone being the
|
|
11
|
+
# primary key) only so products_fts can share its rowid, which
|
|
12
|
+
# stays stable across VACUUM.
|
|
13
|
+
SCHEMA = <<~SQL.freeze
|
|
14
|
+
CREATE TABLE stores (origin TEXT PRIMARY KEY, data TEXT NOT NULL);
|
|
15
|
+
CREATE TABLE products (id INTEGER PRIMARY KEY, key TEXT NOT NULL UNIQUE, data TEXT NOT NULL);
|
|
16
|
+
CREATE TABLE product_stores (key TEXT NOT NULL, origin TEXT NOT NULL, last_seen INTEGER,
|
|
17
|
+
PRIMARY KEY (key, origin));
|
|
18
|
+
CREATE TRIGGER products_stores_ins AFTER INSERT ON products BEGIN
|
|
19
|
+
INSERT OR REPLACE INTO product_stores (key, origin, last_seen)
|
|
20
|
+
SELECT new.key, json_extract(value, '$.origin'), json_extract(value, '$.last_seen')
|
|
21
|
+
FROM json_each(new.data, '$.stores');
|
|
22
|
+
END;
|
|
23
|
+
CREATE TRIGGER products_stores_upd AFTER UPDATE OF data ON products BEGIN
|
|
24
|
+
DELETE FROM product_stores WHERE key = old.key;
|
|
25
|
+
INSERT OR REPLACE INTO product_stores (key, origin, last_seen)
|
|
26
|
+
SELECT new.key, json_extract(value, '$.origin'), json_extract(value, '$.last_seen')
|
|
27
|
+
FROM json_each(new.data, '$.stores');
|
|
28
|
+
END;
|
|
29
|
+
SQL
|
|
30
|
+
|
|
31
|
+
# Needs FTS5 compiled in, which the gem's bundled SQLite has and a
|
|
32
|
+
# system-libraries build might not — see fts5_available?.
|
|
33
|
+
FTS_SCHEMA = <<~SQL.freeze
|
|
34
|
+
CREATE VIRTUAL TABLE products_fts USING fts5(key UNINDEXED, title, brand, category, aliases);
|
|
35
|
+
CREATE TRIGGER products_fts_ins AFTER INSERT ON products BEGIN
|
|
36
|
+
INSERT INTO products_fts (rowid, key, title, brand, category, aliases)
|
|
37
|
+
VALUES (new.id, new.key, json_extract(new.data, '$.title'), json_extract(new.data, '$.brand'),
|
|
38
|
+
json_extract(new.data, '$.category'),
|
|
39
|
+
(SELECT group_concat(value, ' ') FROM json_each(new.data, '$.aliases')));
|
|
40
|
+
END;
|
|
41
|
+
CREATE TRIGGER products_fts_upd AFTER UPDATE OF data ON products BEGIN
|
|
42
|
+
DELETE FROM products_fts WHERE rowid = old.id;
|
|
43
|
+
INSERT INTO products_fts (rowid, key, title, brand, category, aliases)
|
|
44
|
+
VALUES (new.id, new.key, json_extract(new.data, '$.title'), json_extract(new.data, '$.brand'),
|
|
45
|
+
json_extract(new.data, '$.category'),
|
|
46
|
+
(SELECT group_concat(value, ' ') FROM json_each(new.data, '$.aliases')));
|
|
47
|
+
END;
|
|
48
|
+
CREATE TRIGGER products_fts_del AFTER DELETE ON products BEGIN
|
|
49
|
+
DELETE FROM products_fts WHERE rowid = old.id;
|
|
50
|
+
END;
|
|
51
|
+
SQL
|
|
52
|
+
|
|
53
|
+
# Each entry is a script run at that user_version. The FTS one is a
|
|
54
|
+
# no-op on a build without FTS5 (its version still advances).
|
|
55
|
+
MIGRATIONS = [
|
|
56
|
+
->(db) { db.execute_batch(SCHEMA) },
|
|
57
|
+
->(db) { db.execute_batch(FTS_SCHEMA) if Schema.fts5_available? }
|
|
58
|
+
].freeze
|
|
59
|
+
|
|
60
|
+
# @return [Boolean] whether the linked SQLite was built with FTS5.
|
|
61
|
+
def self.fts5_available?
|
|
62
|
+
db = SQLite3::Database.new(":memory:")
|
|
63
|
+
db.execute("PRAGMA compile_options").flatten.include?("ENABLE_FTS5")
|
|
64
|
+
ensure
|
|
65
|
+
db&.close
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
module Index
|
|
6
|
+
# The SQL behind ProductStore#search: an FTS5 MATCH over products_fts
|
|
7
|
+
# (Index::Schema) ranked by bm25, or the same filters as a LIKE scan
|
|
8
|
+
# when the database has no FTS table.
|
|
9
|
+
module Search
|
|
10
|
+
# bm25 weights for products_fts's columns: key (unindexed), title,
|
|
11
|
+
# brand, category, aliases. A title hit outranks a brand-only one.
|
|
12
|
+
BM25 = "bm25(products_fts, 0.0, 10.0, 3.0, 1.0, 5.0)".freeze
|
|
13
|
+
LIKE_COLUMNS = %w[$.title $.brand $.category $.aliases].freeze
|
|
14
|
+
HOST_LIKE = Array.new(4) { "s.origin LIKE ?" }.join(" OR ").freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Letters and digits only, so nothing the user types is FTS5 syntax.
|
|
19
|
+
# A trailing plural ending is dropped and each word matched as a
|
|
20
|
+
# prefix, so "lights" finds "Light" and "Lighting".
|
|
21
|
+
def words(query)
|
|
22
|
+
query.to_s.downcase.scan(/\p{Alnum}+/).map { |w| w.length > 3 ? w.sub(/(?:ies|es|s)\z/, "") : w }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def host_of(store)
|
|
26
|
+
return nil if store.to_s.strip.empty?
|
|
27
|
+
|
|
28
|
+
text = store.to_s.strip
|
|
29
|
+
(text.include?("://") ? URI.parse(text).host : text.split(%r{[/?]}).first).to_s.downcase
|
|
30
|
+
rescue URI::InvalidURIError
|
|
31
|
+
text.downcase
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Array(String, Array)] sql and binds.
|
|
35
|
+
def sql(words, category:, host:, limit:, fts:)
|
|
36
|
+
match_sql, binds = fts ? fts_match(words) : like_match(words)
|
|
37
|
+
filters = [match_sql]
|
|
38
|
+
if category
|
|
39
|
+
filters << "json_extract(p.data, '$.category') = ?"
|
|
40
|
+
binds << category.to_s
|
|
41
|
+
end
|
|
42
|
+
if host
|
|
43
|
+
filters << "EXISTS (SELECT 1 FROM product_stores s WHERE s.key = p.key AND (#{HOST_LIKE}))"
|
|
44
|
+
binds.concat(host_patterns(host))
|
|
45
|
+
end
|
|
46
|
+
[select(fts, filters.join(" AND ")), binds + [limit.to_i]]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The host itself or any subdomain of it, with or without a port —
|
|
50
|
+
# so `--store jbhifi.com.au` finds https://www.jbhifi.com.au.
|
|
51
|
+
def host_patterns(host) = ["%://#{host}", "%://#{host}:%", "%.#{host}", "%.#{host}:%"]
|
|
52
|
+
|
|
53
|
+
def select(fts, where)
|
|
54
|
+
if fts
|
|
55
|
+
"SELECT p.data FROM products_fts JOIN products p ON p.id = products_fts.rowid " \
|
|
56
|
+
"WHERE #{where} ORDER BY #{BM25} LIMIT ?"
|
|
57
|
+
else
|
|
58
|
+
"SELECT p.data FROM products p WHERE #{where} ORDER BY p.id LIMIT ?"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def fts_match(words)
|
|
63
|
+
["products_fts MATCH ?", [words.map { |w| %("#{w}"*) }.join(" ")]]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def like_match(words)
|
|
67
|
+
any_column = LIKE_COLUMNS.map { |path| "lower(json_extract(p.data, '#{path}')) LIKE ?" }.join(" OR ")
|
|
68
|
+
[words.map { "(#{any_column})" }.join(" AND "), words.flat_map { |w| ["%#{w}%"] * LIKE_COLUMNS.length }]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
require "portage/ucp"
|
|
2
|
+
|
|
3
|
+
require_relative "../../../classifier"
|
|
4
|
+
|
|
5
|
+
module Portage
|
|
6
|
+
module Cli
|
|
7
|
+
module Index
|
|
8
|
+
module Sources
|
|
9
|
+
class StorefrontProducts
|
|
10
|
+
# One Shopify `/products.json` product -> Portage::Ucp::Product
|
|
11
|
+
# (docs/plans/local-catalogue.md Phase 2), then the index sighting
|
|
12
|
+
# taken from that Product. The product shape is UCP's, never a
|
|
13
|
+
# parallel "card" vocabulary.
|
|
14
|
+
#
|
|
15
|
+
# Not portage-ucp-shopify's Mapper: that one reads Storefront
|
|
16
|
+
# GraphQL nodes (camelCase, gids, MoneyV2 with a currency), not the
|
|
17
|
+
# REST products.json shape, and portage-cli doesn't depend on that
|
|
18
|
+
# gem.
|
|
19
|
+
#
|
|
20
|
+
# The Product carries what the store sent, price and availability
|
|
21
|
+
# included (products.json has no currency, so Price#currency is
|
|
22
|
+
# nil). #sighting is what the index persists, and it takes only
|
|
23
|
+
# identity fields from the Product: price and availability never
|
|
24
|
+
# reach it.
|
|
25
|
+
module Mapper
|
|
26
|
+
MAX_CATEGORIES = 3
|
|
27
|
+
TAXONOMY = "google_product_category".freeze
|
|
28
|
+
# Shopify's own placeholder for a product with no real options.
|
|
29
|
+
PLACEHOLDER_OPTION = { "name" => "Title", "values" => ["Default Title"] }.freeze
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# @param raw [Hash] one entry of products.json's "products".
|
|
34
|
+
# @param classify [#call] text -> category ids (Classifier).
|
|
35
|
+
def product(raw, origin:, classify: Classifier.method(:categories_for))
|
|
36
|
+
options = product_options(raw)
|
|
37
|
+
Portage::Ucp::Product.new(
|
|
38
|
+
id: "gid://shopify/Product/#{raw['id']}", title: raw["title"].to_s,
|
|
39
|
+
description: Portage::Ucp::Description.new(html: raw["body_html"]),
|
|
40
|
+
price_range: price_range(raw), variants: Array(raw["variants"]).map { |v| variant(v, options) },
|
|
41
|
+
handle: raw["handle"], url: "#{origin}/products/#{raw['handle']}",
|
|
42
|
+
categories: categories(raw, classify), media: Array(raw["images"]).map { |i| media(i) },
|
|
43
|
+
options: options.map { |o| option(o) }, tags: Array(raw["tags"])
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Hash] an Index::Builder sighting: the ProductStore
|
|
48
|
+
# fields plus `product:`, the extra entry fields this source
|
|
49
|
+
# adds (handle, url, image_url, options, variant_ids).
|
|
50
|
+
def sighting(raw, origin:, classify: Classifier.method(:categories_for))
|
|
51
|
+
product = product(raw, origin: origin, classify: classify)
|
|
52
|
+
{ origin: origin, url: product.url, title: product.title, brand: brand(raw), gtin: nil,
|
|
53
|
+
categories: product.categories.select { |c| c.taxonomy == TAXONOMY }.map(&:value),
|
|
54
|
+
product: { handle: product.handle, url: product.url, image_url: product.media.first&.url,
|
|
55
|
+
options: product.options.map(&:to_wire_h), variant_ids: product.variants.map(&:id) } }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def brand(raw)
|
|
59
|
+
vendor = raw["vendor"].to_s.strip
|
|
60
|
+
vendor.empty? ? nil : vendor
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def product_options(raw)
|
|
64
|
+
Array(raw["options"]).reject { |o| o.slice("name", "values") == PLACEHOLDER_OPTION }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def option(raw)
|
|
68
|
+
values = Array(raw["values"]).map { |label| Portage::Ucp::OptionValue.new(label: label.to_s) }
|
|
69
|
+
Portage::Ucp::ProductOption.new(name: raw["name"].to_s, values: values)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def variant(raw, options)
|
|
73
|
+
Portage::Ucp::Variant.new(
|
|
74
|
+
id: "gid://shopify/ProductVariant/#{raw['id']}", title: raw["title"].to_s,
|
|
75
|
+
description: Portage::Ucp::Description.new(plain: raw["title"].to_s), price: price(raw["price"]),
|
|
76
|
+
sku: raw["sku"].to_s.empty? ? nil : raw["sku"],
|
|
77
|
+
list_price: raw["compare_at_price"] ? price(raw["compare_at_price"]) : nil,
|
|
78
|
+
availability: raw.key?("available") ? { "available" => raw["available"] } : nil,
|
|
79
|
+
options: selected_options(raw, options), media: variant_media(raw)
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# option1..option3 line up with the product's options by
|
|
84
|
+
# position; the placeholder option is already gone, so a
|
|
85
|
+
# "Default Title" variant selects nothing.
|
|
86
|
+
def selected_options(raw, options)
|
|
87
|
+
options.each_with_index.filter_map do |opt, i|
|
|
88
|
+
label = raw["option#{i + 1}"]
|
|
89
|
+
Portage::Ucp::SelectedOption.new(name: opt["name"].to_s, label: label.to_s) if label
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def variant_media(raw) = raw["featured_image"] ? [media(raw["featured_image"])] : []
|
|
94
|
+
|
|
95
|
+
def media(raw)
|
|
96
|
+
Portage::Ucp::Media.new(type: "image", url: raw["src"], alt_text: raw["alt"], width: raw["width"],
|
|
97
|
+
height: raw["height"])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def price(amount)
|
|
101
|
+
Portage::Ucp::Price.new(amount: Portage::Ucp::Support::Amounts.decimal_to_minor(amount), currency: nil)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def price_range(raw)
|
|
105
|
+
amounts = Array(raw["variants"]).map { |v| v["price"] }.compact
|
|
106
|
+
amounts = ["0"] if amounts.empty?
|
|
107
|
+
minors = amounts.map { |a| Portage::Ucp::Support::Amounts.decimal_to_minor(a) }
|
|
108
|
+
Portage::Ucp::PriceRange.new(min: Portage::Ucp::Price.new(amount: minors.min, currency: nil),
|
|
109
|
+
max: Portage::Ucp::Price.new(amount: minors.max, currency: nil))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Classifier on product_type and tags (the plan's inputs), top
|
|
113
|
+
# three ids, then the store's own product_type as a merchant
|
|
114
|
+
# category.
|
|
115
|
+
def categories(raw, classify)
|
|
116
|
+
text = [raw["product_type"], *Array(raw["tags"])].compact.join(" ")
|
|
117
|
+
ids = text.strip.empty? ? [] : classify.call(text).first(MAX_CATEGORIES)
|
|
118
|
+
google = ids.map { |id| Portage::Ucp::Category.new(value: id, taxonomy: TAXONOMY) }
|
|
119
|
+
type = raw["product_type"].to_s.strip
|
|
120
|
+
type.empty? ? google : google + [Portage::Ucp::Category.new(value: type, taxonomy: "merchant")]
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|