zeroclick-sellers 0.4.1 → 0.6.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/lib/zeroclick/sellers/agentify.rb +208 -0
- data/lib/zeroclick/sellers/client.rb +83 -3
- data/lib/zeroclick/sellers/contracts.rb +7 -0
- data/lib/zeroclick/sellers/errors.rb +4 -0
- data/lib/zeroclick/sellers/middleware/agentify.rb +113 -0
- data/lib/zeroclick/sellers/middleware/page_views.rb +177 -0
- data/lib/zeroclick/sellers/page_views.rb +67 -0
- data/lib/zeroclick/sellers/version.rb +1 -1
- data/lib/zeroclick/sellers.rb +4 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6127c1b9413ac6cbf8d9690e972e319418e2bee6ba3a025ba057c6752d04a370
|
|
4
|
+
data.tar.gz: 384bfdd1ba3393497943aa9c37138f0b9fae69098a2404b3dfd6b2e51f61493b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 446af6c8cb1563130c222f09ec8ee8eeb4308636782925acd269d7353a1ed3cbc52f7aa8ac033fb0a1eb6bb4fe268b2d5178a5b483e7e7cf9348c18ea291a99d
|
|
7
|
+
data.tar.gz: 40f965870f70c61902865c586d930c39f4c1165a943244dfc6647d05abdbc93bb2faf65f184188cc80e03e45a860b5c410c5db8d2c97f48545206a5a663657cc
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
require_relative "usage"
|
|
8
|
+
|
|
9
|
+
module ZeroClick
|
|
10
|
+
module Sellers
|
|
11
|
+
# Agentify: serve a marketing page as agent-optimized markdown.
|
|
12
|
+
#
|
|
13
|
+
# Detection is one pure boolean over two request headers; the conversion is
|
|
14
|
+
# a GET against ZeroClick's Agentify API, authenticated with a zc_ key
|
|
15
|
+
# carrying the agentify:convert scope. As in Usage, transport and
|
|
16
|
+
# interpretation are separate: interpret_markdown is pure, so what a
|
|
17
|
+
# response MEANS is decided in one place and tested without a socket.
|
|
18
|
+
module Agentify
|
|
19
|
+
# Calendar version of the detection rules below, matching the
|
|
20
|
+
# `detectionVersion` in agentify-detection-vectors.json. The vector file
|
|
21
|
+
# is generated by packages/sellers-python/tests/vectors/
|
|
22
|
+
# generate_agentify_detection_vectors.py — the authority on this
|
|
23
|
+
# contract — and a vector test pins this constant and the substring list
|
|
24
|
+
# to it, so an update cannot land here without regenerating the vectors
|
|
25
|
+
# (and vice versa).
|
|
26
|
+
DETECTION_VERSION = "2026-08-25.01"
|
|
27
|
+
|
|
28
|
+
# Case-insensitive substrings that mark an AI agent's user-agent: the
|
|
29
|
+
# AI-operator crawlers, assistant fetchers, and browser agents. Classic
|
|
30
|
+
# search crawlers (googlebot, bingbot, ...) are deliberately absent —
|
|
31
|
+
# serving them different content than browsers is cloaking — and so are
|
|
32
|
+
# generic http tools (curl, python, ...), which ask for markdown via
|
|
33
|
+
# Accept when they want it.
|
|
34
|
+
AI_USER_AGENT_SUBSTRINGS = %w[
|
|
35
|
+
anthropic
|
|
36
|
+
bytespider
|
|
37
|
+
ccbot
|
|
38
|
+
chatgpt
|
|
39
|
+
claude
|
|
40
|
+
duckassistbot
|
|
41
|
+
google-cloudvertexbot
|
|
42
|
+
googleagent-mariner
|
|
43
|
+
gptbot
|
|
44
|
+
meta-externalagent
|
|
45
|
+
meta-externalfetcher
|
|
46
|
+
mistralai
|
|
47
|
+
oai-searchbot
|
|
48
|
+
openai
|
|
49
|
+
perplexity
|
|
50
|
+
].freeze
|
|
51
|
+
|
|
52
|
+
MARKDOWN_PATH = "/v1/agentify/markdown"
|
|
53
|
+
|
|
54
|
+
# The converted document plus the response headers worth forwarding to
|
|
55
|
+
# the agent — the customer's CDN caches the markdown too.
|
|
56
|
+
class MarkdownResult
|
|
57
|
+
attr_reader :markdown, :cache_control, :content_type, :etag, :zcj_id
|
|
58
|
+
|
|
59
|
+
# +zcj_id+ is the journey id the conversion minted (x-zcj-id), or nil
|
|
60
|
+
# when the chain is off for this seller. Forwarded onto the agent's
|
|
61
|
+
# response so a wrapping page-view beacon can stitch this marketing
|
|
62
|
+
# view to the same journey as the storefront visit and purchase the
|
|
63
|
+
# agent reaches through the inlined links.
|
|
64
|
+
def initialize(markdown:, cache_control: nil, content_type: nil, etag: nil, zcj_id: nil)
|
|
65
|
+
@markdown = markdown
|
|
66
|
+
@cache_control = cache_control
|
|
67
|
+
@content_type = content_type
|
|
68
|
+
@etag = etag
|
|
69
|
+
@zcj_id = zcj_id
|
|
70
|
+
freeze
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
module_function
|
|
75
|
+
|
|
76
|
+
# Whether a request should be answered with agentified markdown: the
|
|
77
|
+
# client either negotiates for it (Accept prefers text/markdown over
|
|
78
|
+
# text/html) or announces an AI agent user-agent. A pure predicate over
|
|
79
|
+
# the two header values — absent or empty headers are simply false,
|
|
80
|
+
# never an error.
|
|
81
|
+
def wants_agent_markdown?(accept: nil, user_agent: nil)
|
|
82
|
+
prefers_markdown?(accept) || ai_user_agent?(user_agent)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Whether text/markdown outranks text/html in an Accept header.
|
|
86
|
+
#
|
|
87
|
+
# Agent fetchers (Claude's WebFetch and kin) send `Accept:
|
|
88
|
+
# text/markdown, text/html, */*`; browsers never list text/markdown.
|
|
89
|
+
# Markdown wins only when explicitly listed with q > 0 and not outranked
|
|
90
|
+
# by text/html (higher q, or listed first on a tie). Ported from the
|
|
91
|
+
# platform's negotiation and pinned by the shared detection vectors: the
|
|
92
|
+
# first entry of each media type and the first parseable q parameter of
|
|
93
|
+
# an entry count.
|
|
94
|
+
def prefers_markdown?(accept)
|
|
95
|
+
return false if accept.nil? || accept.empty?
|
|
96
|
+
|
|
97
|
+
markdown = nil
|
|
98
|
+
html = nil
|
|
99
|
+
accept.downcase.split(",").each_with_index do |raw_entry, index|
|
|
100
|
+
parts = raw_entry.strip.split(";")
|
|
101
|
+
media_type = parts[0].to_s.strip
|
|
102
|
+
q = 1.0
|
|
103
|
+
parts[1..].each do |raw_param|
|
|
104
|
+
param = raw_param.strip
|
|
105
|
+
next unless param.start_with?("q=")
|
|
106
|
+
|
|
107
|
+
# An unparseable q contributes nothing: the default of 1 stands
|
|
108
|
+
# unless a later q parameter parses.
|
|
109
|
+
value = q_value(param)
|
|
110
|
+
next if value.nil?
|
|
111
|
+
|
|
112
|
+
q = value
|
|
113
|
+
break
|
|
114
|
+
end
|
|
115
|
+
entry = { q: q, index: index }
|
|
116
|
+
markdown ||= entry if media_type == "text/markdown"
|
|
117
|
+
html ||= entry if media_type == "text/html"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
return false if markdown.nil? || markdown[:q] <= 0
|
|
121
|
+
return true if html.nil? || html[:q] <= 0
|
|
122
|
+
|
|
123
|
+
markdown[:q] > html[:q] || (markdown[:q] == html[:q] && markdown[:index] < html[:index])
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def ai_user_agent?(user_agent)
|
|
127
|
+
return false if user_agent.nil? || user_agent.empty?
|
|
128
|
+
|
|
129
|
+
lowered = user_agent.downcase
|
|
130
|
+
AI_USER_AGENT_SUBSTRINGS.any? { |token| lowered.include?(token) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The q of one already-trimmed "q=..." parameter, or nil when unusable.
|
|
134
|
+
def q_value(param)
|
|
135
|
+
value = Float(param[2..])
|
|
136
|
+
value.nan? || value.infinite? ? nil : value
|
|
137
|
+
rescue ArgumentError, TypeError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# ----------------------------------------------------------- build
|
|
142
|
+
|
|
143
|
+
# Reject anything but an absolute http(s) URL before it reaches the API.
|
|
144
|
+
def validate_page_url!(url, operation:)
|
|
145
|
+
parsed = begin
|
|
146
|
+
URI.parse(url.to_s)
|
|
147
|
+
rescue URI::InvalidURIError
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
return if parsed.is_a?(URI::HTTP) && !parsed.host.nil? && !parsed.host.empty?
|
|
151
|
+
|
|
152
|
+
raise Error.new("malformed_input", operation: operation,
|
|
153
|
+
message: "url must be an absolute http(s) URL, got #{url.inspect}")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def request_headers(api_key)
|
|
157
|
+
{
|
|
158
|
+
"accept" => "text/markdown",
|
|
159
|
+
"authorization" => "Bearer #{api_key}",
|
|
160
|
+
"user-agent" => Usage::USER_AGENT
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------- interpret
|
|
165
|
+
|
|
166
|
+
def interpret_markdown(status, body, headers)
|
|
167
|
+
raise Error.new("api_status_error", operation: "fetch_agentify_markdown", status: status) unless (200..299).cover?(status)
|
|
168
|
+
|
|
169
|
+
MarkdownResult.new(
|
|
170
|
+
markdown: body,
|
|
171
|
+
cache_control: headers["cache-control"],
|
|
172
|
+
content_type: headers["content-type"],
|
|
173
|
+
etag: headers["etag"],
|
|
174
|
+
zcj_id: headers["x-zcj-id"]
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------- transport
|
|
179
|
+
|
|
180
|
+
# Returns [status, body, headers] with lowercase header names. Raises
|
|
181
|
+
# only api_transport_error — an HTTP status is data here, not a failure,
|
|
182
|
+
# and interpretation happens above.
|
|
183
|
+
def get(base_url:, path:, query:, api_key:, timeout:, operation:)
|
|
184
|
+
uri = URI.join(base_url, path)
|
|
185
|
+
uri.query = URI.encode_www_form(query)
|
|
186
|
+
|
|
187
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
188
|
+
http.use_ssl = uri.scheme == "https"
|
|
189
|
+
# Both halves, or a server that accepts the connection and then stalls
|
|
190
|
+
# hangs the request past the caller's budget.
|
|
191
|
+
http.open_timeout = timeout
|
|
192
|
+
http.read_timeout = timeout
|
|
193
|
+
http.write_timeout = timeout
|
|
194
|
+
|
|
195
|
+
request = Net::HTTP::Get.new(uri)
|
|
196
|
+
request_headers(api_key).each { |name, value| request[name] = value }
|
|
197
|
+
|
|
198
|
+
response = http.request(request)
|
|
199
|
+
headers = response.each_header.to_h { |name, value| [name.downcase, value] }
|
|
200
|
+
[response.code.to_i, response.body.to_s, headers]
|
|
201
|
+
rescue *Usage::TRANSPORT_ERRORS => e
|
|
202
|
+
raise Error.new("api_transport_error", operation: operation, cause: e.message)
|
|
203
|
+
ensure
|
|
204
|
+
http&.finish if http&.started?
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "agentify"
|
|
3
4
|
require_relative "contracts"
|
|
4
5
|
require_relative "errors"
|
|
6
|
+
require_relative "page_views"
|
|
5
7
|
require_relative "responses"
|
|
6
8
|
require_relative "usage"
|
|
7
9
|
require_relative "verify"
|
|
@@ -25,20 +27,32 @@ module ZeroClick
|
|
|
25
27
|
def initialize(api_key: nil,
|
|
26
28
|
usage_read_key: nil,
|
|
27
29
|
usage_write_key: nil,
|
|
30
|
+
agentify_key: nil,
|
|
31
|
+
page_views_key: nil,
|
|
28
32
|
signing_secrets: nil,
|
|
29
33
|
resolve_signing_secret: nil,
|
|
30
34
|
api_base_url: DEFAULT_API_BASE_URL,
|
|
31
35
|
tolerance_seconds: DEFAULT_TOLERANCE_SECONDS,
|
|
32
36
|
allowance_unavailable_policy: "allow",
|
|
33
37
|
check_timeout_seconds: DEFAULT_CHECK_TIMEOUT_SECONDS,
|
|
38
|
+
agentify_timeout_seconds: DEFAULT_AGENTIFY_TIMEOUT_SECONDS,
|
|
39
|
+
page_view_timeout_seconds: DEFAULT_PAGE_VIEW_TIMEOUT_SECONDS,
|
|
34
40
|
on_allowance_unavailable: nil,
|
|
35
41
|
clock: -> { Time.now.to_i },
|
|
36
|
-
# The
|
|
42
|
+
# The transports, injectable so every decision this class
|
|
37
43
|
# makes about a response is testable without a socket.
|
|
38
|
-
#
|
|
39
|
-
|
|
44
|
+
# http_post must return [status, body]; http_get must
|
|
45
|
+
# return [status, body, headers]. Either may raise Error.
|
|
46
|
+
http_post: Usage.method(:post),
|
|
47
|
+
http_get: Agentify.method(:get))
|
|
40
48
|
usage_read_key ||= api_key
|
|
41
49
|
usage_write_key ||= api_key
|
|
50
|
+
# Unlike the usage keys, agentify and page views have no
|
|
51
|
+
# construction-time requirement: each capability is optional, so a
|
|
52
|
+
# missing key surfaces as a typed error at call time instead of failing
|
|
53
|
+
# every create that never uses it.
|
|
54
|
+
agentify_key ||= api_key
|
|
55
|
+
page_views_key ||= api_key
|
|
42
56
|
|
|
43
57
|
if usage_read_key.nil? || usage_read_key.empty?
|
|
44
58
|
raise Error.new("malformed_input", operation: "create",
|
|
@@ -63,15 +77,20 @@ module ZeroClick
|
|
|
63
77
|
|
|
64
78
|
@usage_read_key = usage_read_key
|
|
65
79
|
@usage_write_key = usage_write_key
|
|
80
|
+
@agentify_key = agentify_key
|
|
81
|
+
@page_views_key = page_views_key
|
|
66
82
|
@signing_secrets = signing_secrets&.to_h&.freeze
|
|
67
83
|
@resolve_signing_secret = resolve_signing_secret
|
|
68
84
|
@api_base_url = api_base_url
|
|
69
85
|
@tolerance_seconds = tolerance_seconds
|
|
70
86
|
@policy = allowance_unavailable_policy.to_s
|
|
71
87
|
@timeout = check_timeout_seconds
|
|
88
|
+
@agentify_timeout = agentify_timeout_seconds
|
|
89
|
+
@page_view_timeout = page_view_timeout_seconds
|
|
72
90
|
@on_allowance_unavailable = on_allowance_unavailable
|
|
73
91
|
@clock = clock
|
|
74
92
|
@http_post = http_post
|
|
93
|
+
@http_get = http_get
|
|
75
94
|
freeze
|
|
76
95
|
end
|
|
77
96
|
|
|
@@ -133,6 +152,67 @@ module ZeroClick
|
|
|
133
152
|
Allow.new(verification.context, "not_required")
|
|
134
153
|
end
|
|
135
154
|
|
|
155
|
+
# Convert a public marketing page via GET /v1/agentify/markdown.
|
|
156
|
+
#
|
|
157
|
+
# Requires an API key with the agentify:convert scope (+agentify_key+,
|
|
158
|
+
# falling back to the both-scopes +api_key+). +seller+ is the seller
|
|
159
|
+
# whose storefront the document inlines (its public id); an organization
|
|
160
|
+
# with exactly one active seller never needs it. Returns an
|
|
161
|
+
# Agentify::MarkdownResult carrying the markdown plus the cache headers
|
|
162
|
+
# worth forwarding.
|
|
163
|
+
def fetch_agentify_markdown(url, seller: nil)
|
|
164
|
+
if @agentify_key.nil? || @agentify_key.empty?
|
|
165
|
+
raise Error.new("agentify_not_configured", operation: "fetch_agentify_markdown")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
Agentify.validate_page_url!(url, operation: "fetch_agentify_markdown")
|
|
169
|
+
query = { "url" => url.to_s }
|
|
170
|
+
query["seller"] = seller unless seller.nil?
|
|
171
|
+
status, body, headers = @http_get.call(
|
|
172
|
+
base_url: @api_base_url,
|
|
173
|
+
path: Agentify::MARKDOWN_PATH,
|
|
174
|
+
query: query,
|
|
175
|
+
api_key: @agentify_key,
|
|
176
|
+
timeout: @agentify_timeout,
|
|
177
|
+
operation: "fetch_agentify_markdown"
|
|
178
|
+
)
|
|
179
|
+
Agentify.interpret_markdown(status, body, headers)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Report one marketing-site page view to POST /v1/page-views.
|
|
183
|
+
#
|
|
184
|
+
# Requires an API key with the page-views:write scope (+page_views_key+,
|
|
185
|
+
# falling back to the both-scopes +api_key+). Fire-and-forget by design:
|
|
186
|
+
# the API answers 204 without waiting for warehouse delivery. Raises a
|
|
187
|
+
# typed Error on transport failure or a non-2xx status so a direct caller
|
|
188
|
+
# can observe problems; the Middleware::PageViews wrapper swallows those
|
|
189
|
+
# so a beacon failure never affects the page being observed.
|
|
190
|
+
#
|
|
191
|
+
# +seller+ is the seller's public id; +path+ must be an absolute path.
|
|
192
|
+
def report_page_view(seller:, path:, status: nil, duration_ms: nil, representation: nil,
|
|
193
|
+
journey_id: nil, captured_at: nil, user_agent: nil, accept: nil,
|
|
194
|
+
client_ip: nil, country: nil, referrer_host: nil)
|
|
195
|
+
if @page_views_key.nil? || @page_views_key.empty?
|
|
196
|
+
raise Error.new("page_views_not_configured", operation: "report_page_view")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
payload = PageViews.build_payload(
|
|
200
|
+
seller: seller, path: path, status: status, duration_ms: duration_ms,
|
|
201
|
+
representation: representation, journey_id: journey_id, captured_at: captured_at,
|
|
202
|
+
user_agent: user_agent, accept: accept, client_ip: client_ip,
|
|
203
|
+
country: country, referrer_host: referrer_host
|
|
204
|
+
)
|
|
205
|
+
status_code, body = @http_post.call(
|
|
206
|
+
base_url: @api_base_url,
|
|
207
|
+
path: PageViews::REPORT_PATH,
|
|
208
|
+
api_key: @page_views_key,
|
|
209
|
+
payload: payload,
|
|
210
|
+
timeout: @page_view_timeout,
|
|
211
|
+
operation: "report_page_view"
|
|
212
|
+
)
|
|
213
|
+
PageViews.interpret_report(status_code, body)
|
|
214
|
+
end
|
|
215
|
+
|
|
136
216
|
def check_allowance(zc_request_id:, service_slug:, usage:)
|
|
137
217
|
status, body = @http_post.call(
|
|
138
218
|
base_url: @api_base_url,
|
|
@@ -9,6 +9,13 @@ module ZeroClick
|
|
|
9
9
|
DEFAULT_API_BASE_URL = "https://api.zeroclick.io"
|
|
10
10
|
DEFAULT_TOLERANCE_SECONDS = 300
|
|
11
11
|
DEFAULT_CHECK_TIMEOUT_SECONDS = 1.5
|
|
12
|
+
# A cold conversion runs the full pipeline on the API side (page fetch,
|
|
13
|
+
# optional browser render, LLM cleanup), so agentify calls get a larger
|
|
14
|
+
# timeout default than the allowance check.
|
|
15
|
+
DEFAULT_AGENTIFY_TIMEOUT_SECONDS = 10.0
|
|
16
|
+
# The page-view beacon is a cheap fire-and-forget 204; its default timeout
|
|
17
|
+
# is short so a slow ingress never delays the page it is observing.
|
|
18
|
+
DEFAULT_PAGE_VIEW_TIMEOUT_SECONDS = 2.0
|
|
12
19
|
|
|
13
20
|
# Reasons the allowance API gives for refusing. Any other reason on the wire
|
|
14
21
|
# is treated as a malformed response rather than silently passed through.
|
|
@@ -8,10 +8,14 @@ module ZeroClick
|
|
|
8
8
|
# environment did (the ZeroClick API unreachable or answering nonsense).
|
|
9
9
|
class Error < StandardError
|
|
10
10
|
DEFAULT_MESSAGES = {
|
|
11
|
+
"agentify_not_configured" => "No API key covers agentify calls (set agentify_key, or an api_key " \
|
|
12
|
+
"with the agentify:convert scope)",
|
|
11
13
|
"api_response_invalid" => "The ZeroClick API response is malformed",
|
|
12
14
|
"api_status_error" => "The ZeroClick API returned an unsuccessful status",
|
|
13
15
|
"api_transport_error" => "The ZeroClick API request failed",
|
|
14
16
|
"malformed_input" => "The SDK input is malformed",
|
|
17
|
+
"page_views_not_configured" => "No API key covers page-view calls (set page_views_key, or an api_key " \
|
|
18
|
+
"with the page-views:write scope)",
|
|
15
19
|
"signing_secret_resolution_failed" => "The signing secret could not be resolved"
|
|
16
20
|
}.freeze
|
|
17
21
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../agentify"
|
|
4
|
+
require_relative "../middleware"
|
|
5
|
+
|
|
6
|
+
module ZeroClick
|
|
7
|
+
module Sellers
|
|
8
|
+
module Middleware
|
|
9
|
+
# Rack middleware that serves agent traffic agentified markdown and
|
|
10
|
+
# passes everyone else to the app untouched. Mount it in front of the
|
|
11
|
+
# marketing site, beside Meter/Identify:
|
|
12
|
+
#
|
|
13
|
+
# use ZeroClick::Sellers::Middleware::Agentify, seller: SELLER
|
|
14
|
+
#
|
|
15
|
+
# The contract, shared by every ZeroClick seller SDK:
|
|
16
|
+
#
|
|
17
|
+
# - only GET requests are considered; every other method passes
|
|
18
|
+
# through;
|
|
19
|
+
# - detection is Agentify.wants_agent_markdown? over the request's
|
|
20
|
+
# Accept and User-Agent headers — markdown-preferring or AI-agent
|
|
21
|
+
# traffic matches;
|
|
22
|
+
# - a match short-circuits with 200 text/markdown, forwarding the
|
|
23
|
+
# agentify response's cache-control and etag so the customer's CDN
|
|
24
|
+
# caches it too;
|
|
25
|
+
# - ANY agentify failure — missing key, network error, non-200,
|
|
26
|
+
# timeout — falls through to the app. The middleware must never
|
|
27
|
+
# break the customer's site; the worst case is an agent seeing HTML.
|
|
28
|
+
#
|
|
29
|
+
# Unlike Meter/Identify it never reads the request body — there is
|
|
30
|
+
# nothing to verify — so no buffering ceiling applies.
|
|
31
|
+
class Agentify
|
|
32
|
+
# +seller+ may be a client, a callable returning one, or omitted to
|
|
33
|
+
# resolve the process-wide ZeroClick::Sellers.seller on the first
|
|
34
|
+
# request (the Rails path — see Base#initialize for why).
|
|
35
|
+
#
|
|
36
|
+
# +seller_id+ is forwarded to the API's seller query param (its public
|
|
37
|
+
# id); needed only by multi-seller organizations. +resolve_url+ is a
|
|
38
|
+
# callable taking the Rack env and returning the public URL to
|
|
39
|
+
# convert, for servers behind a proxy that rewrites the scheme or host
|
|
40
|
+
# the runtime sees. +on_error+ observes the fail-open path; it is
|
|
41
|
+
# never re-raised.
|
|
42
|
+
def initialize(app, seller: nil, seller_id: nil, resolve_url: nil, on_error: nil)
|
|
43
|
+
@app = app
|
|
44
|
+
@seller_source = seller
|
|
45
|
+
@seller_id = seller_id
|
|
46
|
+
@resolve_url = resolve_url
|
|
47
|
+
@on_error = on_error
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def call(env)
|
|
51
|
+
return @app.call(env) unless env["REQUEST_METHOD"] == "GET"
|
|
52
|
+
|
|
53
|
+
wanted = Sellers::Agentify.wants_agent_markdown?(
|
|
54
|
+
accept: env["HTTP_ACCEPT"], user_agent: env["HTTP_USER_AGENT"]
|
|
55
|
+
)
|
|
56
|
+
return @app.call(env) unless wanted
|
|
57
|
+
|
|
58
|
+
begin
|
|
59
|
+
url = @resolve_url ? @resolve_url.call(env) : Agentify.public_url(env)
|
|
60
|
+
result = seller.fetch_agentify_markdown(url, seller: @seller_id)
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
# Fail open — never break the site; the worst case is HTML.
|
|
63
|
+
@on_error&.call(e, env)
|
|
64
|
+
return @app.call(env)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
headers = {
|
|
68
|
+
"content-type" => result.content_type || "text/markdown; charset=utf-8",
|
|
69
|
+
# The representation depends on both detection signals; without
|
|
70
|
+
# this a shared cache would hand the markdown to a browser.
|
|
71
|
+
"vary" => "accept, user-agent"
|
|
72
|
+
}
|
|
73
|
+
headers["cache-control"] = result.cache_control unless result.cache_control.nil?
|
|
74
|
+
headers["etag"] = result.etag unless result.etag.nil?
|
|
75
|
+
unless result.zcj_id.nil?
|
|
76
|
+
# Re-expose the journey id on the agent's response so a wrapping
|
|
77
|
+
# page-view beacon can stamp this view onto the same journey.
|
|
78
|
+
headers["x-zcj-id"] = result.zcj_id
|
|
79
|
+
headers["access-control-expose-headers"] = "x-zcj-id"
|
|
80
|
+
end
|
|
81
|
+
[200, headers, [result.markdown]]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The URL the agent requested, as far as this process can see it.
|
|
85
|
+
def self.public_url(env)
|
|
86
|
+
scheme = env["rack.url_scheme"] || "http"
|
|
87
|
+
host = env["HTTP_HOST"]
|
|
88
|
+
if host.nil? || host.empty?
|
|
89
|
+
host = env["SERVER_NAME"].to_s
|
|
90
|
+
port = env["SERVER_PORT"]
|
|
91
|
+
default_port = scheme == "https" ? "443" : "80"
|
|
92
|
+
host = "#{host}:#{port}" if port && port != default_port
|
|
93
|
+
end
|
|
94
|
+
"#{scheme}://#{host}#{Middleware.path_and_query_from_env(env)}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Resolved once, on first use rather than at wire-up, exactly as
|
|
100
|
+
# Base#seller does — see that comment for the Rails ordering.
|
|
101
|
+
def seller
|
|
102
|
+
@seller ||= if @seller_source.nil?
|
|
103
|
+
Sellers.seller
|
|
104
|
+
elsif @seller_source.respond_to?(:call)
|
|
105
|
+
@seller_source.call
|
|
106
|
+
else
|
|
107
|
+
@seller_source
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
require_relative "../middleware"
|
|
7
|
+
require_relative "agentify"
|
|
8
|
+
|
|
9
|
+
module ZeroClick
|
|
10
|
+
module Sellers
|
|
11
|
+
module Middleware
|
|
12
|
+
# Rack middleware that emits a fire-and-forget page-view beacon for every
|
|
13
|
+
# page-content request, without changing the response. Mount it in front
|
|
14
|
+
# of the marketing site:
|
|
15
|
+
#
|
|
16
|
+
# use ZeroClick::Sellers::Middleware::PageViews, seller: SELLER, seller_id: "sel_public"
|
|
17
|
+
#
|
|
18
|
+
# The contract, shared by every ZeroClick seller SDK:
|
|
19
|
+
#
|
|
20
|
+
# - only GET requests to non-asset paths are reported (an asset is a
|
|
21
|
+
# final path segment ending in an extension — .css, .ico, .png);
|
|
22
|
+
# - the app runs FIRST, so the beacon carries the real status and
|
|
23
|
+
# duration; the visitor's user-agent, accept, and connecting IP are
|
|
24
|
+
# forwarded for server-side classification;
|
|
25
|
+
# - ANY beacon failure — missing key, network error, non-204, timeout,
|
|
26
|
+
# a bad resolve_url or clock — is handed to +on_error+ and swallowed.
|
|
27
|
+
# The middleware must never break or alter the customer's site.
|
|
28
|
+
#
|
|
29
|
+
# Compose it as the OUTER middleware around Agentify — mount PageViews
|
|
30
|
+
# before Agentify — so the agent-markdown variant's re-exposed x-zcj-id is
|
|
31
|
+
# visible on the response and stitches the view onto the same journey.
|
|
32
|
+
#
|
|
33
|
+
# Like Agentify it never reads the request body, so no buffering ceiling
|
|
34
|
+
# applies.
|
|
35
|
+
class PageViews
|
|
36
|
+
# A page-content request has no asset extension on its final path
|
|
37
|
+
# segment. This skips /_astro/app.css, /favicon.ico, /logo.png and the
|
|
38
|
+
# like while still reporting /pricing, /blog/post, and / for both
|
|
39
|
+
# browsers and agents.
|
|
40
|
+
ASSET_PATH = /\.[a-z0-9]+$/i
|
|
41
|
+
|
|
42
|
+
# Wall clock in epoch milliseconds, for the observation time and the
|
|
43
|
+
# duration. Injectable so a test can make both deterministic.
|
|
44
|
+
DEFAULT_CLOCK = -> { Process.clock_gettime(Process::CLOCK_REALTIME, :float_millisecond) }
|
|
45
|
+
|
|
46
|
+
# +seller+ may be a client, a callable returning one, or omitted to
|
|
47
|
+
# resolve the process-wide ZeroClick::Sellers.seller on the first
|
|
48
|
+
# request (the Rails path — see Base#initialize for why).
|
|
49
|
+
#
|
|
50
|
+
# +seller_id+ is the seller's public id, sent in every beacon (required
|
|
51
|
+
# by the wire contract). +clock+ returns epoch milliseconds.
|
|
52
|
+
# +resolve_url+ is a callable taking the Rack env and returning the
|
|
53
|
+
# public URL of the request, for servers behind a proxy that rewrites
|
|
54
|
+
# the host the runtime sees. +on_error+ observes the fire-and-forget
|
|
55
|
+
# path; it is never re-raised.
|
|
56
|
+
def initialize(app, seller_id:, seller: nil, clock: nil, resolve_url: nil, on_error: nil)
|
|
57
|
+
@app = app
|
|
58
|
+
@seller_source = seller
|
|
59
|
+
@seller_id = seller_id
|
|
60
|
+
@clock = clock || DEFAULT_CLOCK
|
|
61
|
+
@resolve_url = resolve_url
|
|
62
|
+
@on_error = on_error
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def call(env)
|
|
66
|
+
started_at = @clock.call
|
|
67
|
+
status, headers, body = @app.call(env)
|
|
68
|
+
|
|
69
|
+
# The whole observation path is wrapped: a bad resolve_url, an
|
|
70
|
+
# unparseable URL, a misbehaving clock, or the beacon delivery itself
|
|
71
|
+
# must never break the page. Every failure — synchronous or from the
|
|
72
|
+
# blocking beacon — is routed to on_error, and the app's response is
|
|
73
|
+
# returned untouched regardless.
|
|
74
|
+
begin
|
|
75
|
+
observe(env, started_at: started_at, status: status, headers: headers)
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
@on_error&.call(e, env)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
[status, headers, body]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def observe(env, started_at:, status:, headers:)
|
|
86
|
+
return unless env["REQUEST_METHOD"] == "GET"
|
|
87
|
+
|
|
88
|
+
url = @resolve_url ? @resolve_url.call(env) : Agentify.public_url(env)
|
|
89
|
+
path = URI.parse(url.to_s).path
|
|
90
|
+
path = "/" if path.nil? || path.empty?
|
|
91
|
+
return if ASSET_PATH.match?(path)
|
|
92
|
+
|
|
93
|
+
seller.report_page_view(
|
|
94
|
+
seller: @seller_id,
|
|
95
|
+
path: path,
|
|
96
|
+
status: status.to_i,
|
|
97
|
+
duration_ms: [0, (@clock.call - started_at).round].max,
|
|
98
|
+
representation: representation_of(headers),
|
|
99
|
+
captured_at: Time.at(started_at / 1000.0).utc.iso8601(3),
|
|
100
|
+
user_agent: env["HTTP_USER_AGENT"],
|
|
101
|
+
accept: env["HTTP_ACCEPT"],
|
|
102
|
+
client_ip: client_ip_of(env),
|
|
103
|
+
country: env["HTTP_CF_IPCOUNTRY"],
|
|
104
|
+
referrer_host: referrer_host_of(env),
|
|
105
|
+
journey_id: journey_id_of(headers)
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# What the app served for this page-content request, read off the
|
|
110
|
+
# response content-type so it reflects the real representation
|
|
111
|
+
# regardless of what produced it.
|
|
112
|
+
def representation_of(headers)
|
|
113
|
+
header_value(headers, "content-type").to_s.include?("markdown") ? "markdown" : "html"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The journey id an upstream Agentify conversion minted for this same
|
|
117
|
+
# request, re-exposed by Middleware::Agentify. Present only for the
|
|
118
|
+
# agent-markdown variant; omitted otherwise.
|
|
119
|
+
def journey_id_of(headers)
|
|
120
|
+
header_value(headers, "x-zcj-id")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The forwarded client IP, from the edge's connecting-IP headers. The
|
|
124
|
+
# API turns it into the viewer id and never stores it raw; nil when the
|
|
125
|
+
# runtime exposes no client address.
|
|
126
|
+
def client_ip_of(env)
|
|
127
|
+
direct = env["HTTP_CF_CONNECTING_IP"] || env["HTTP_X_REAL_IP"]
|
|
128
|
+
return direct.strip if direct && !direct.strip.empty?
|
|
129
|
+
|
|
130
|
+
forwarded = env["HTTP_X_FORWARDED_FOR"]
|
|
131
|
+
return nil if forwarded.nil?
|
|
132
|
+
|
|
133
|
+
first = forwarded.split(",").first.to_s.strip
|
|
134
|
+
first.empty? ? nil : first
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The referrer reduced to its host at the edge, so the full referrer
|
|
138
|
+
# (its path and query, which can carry identifying values) never leaves
|
|
139
|
+
# the site. A malformed referrer yields no host rather than the full URL.
|
|
140
|
+
def referrer_host_of(env)
|
|
141
|
+
referer = env["HTTP_REFERER"]
|
|
142
|
+
return nil if referer.nil? || referer.empty?
|
|
143
|
+
|
|
144
|
+
uri = begin
|
|
145
|
+
URI.parse(referer)
|
|
146
|
+
rescue URI::InvalidURIError
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
host = uri&.host
|
|
150
|
+
return nil if host.nil? || host.empty?
|
|
151
|
+
|
|
152
|
+
# Host and any non-default port only, matching the JS SDK's URL.host.
|
|
153
|
+
uri.port && uri.port != uri.default_port ? "#{host}:#{uri.port}" : host
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Case-insensitive lookup over the app's response headers.
|
|
157
|
+
def header_value(headers, name)
|
|
158
|
+
target = name.downcase
|
|
159
|
+
headers.each { |key, value| return value if key.to_s.downcase == target }
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Resolved once, on first use rather than at wire-up, exactly as
|
|
164
|
+
# Base#seller does — see that comment for the Rails ordering.
|
|
165
|
+
def seller
|
|
166
|
+
@seller ||= if @seller_source.nil?
|
|
167
|
+
Sellers.seller
|
|
168
|
+
elsif @seller_source.respond_to?(:call)
|
|
169
|
+
@seller_source.call
|
|
170
|
+
else
|
|
171
|
+
@seller_source
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "contracts"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
module ZeroClick
|
|
7
|
+
module Sellers
|
|
8
|
+
# POST /v1/page-views: one fire-and-forget marketing page-view beacon.
|
|
9
|
+
#
|
|
10
|
+
# As in Usage and Agentify, building the payload and interpreting the
|
|
11
|
+
# response are separate from transport, so what a response MEANS is decided
|
|
12
|
+
# in one pure place. Unlike the usage endpoints the API answers 204 with no
|
|
13
|
+
# body, so the success path parses nothing — see #interpret_report.
|
|
14
|
+
#
|
|
15
|
+
# The visitor's user-agent/accept/IP are forwarded so the API can classify
|
|
16
|
+
# the viewer and derive the shared viewer id; they are consumed there and
|
|
17
|
+
# never stored raw. Only the path is ever sent, never the query.
|
|
18
|
+
module PageViews
|
|
19
|
+
REPORT_PATH = "/v1/page-views"
|
|
20
|
+
|
|
21
|
+
# What the site actually served for a page-content request: the agent
|
|
22
|
+
# markdown variant, or the normal HTML.
|
|
23
|
+
REPRESENTATIONS = %w[html markdown].freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# Build the wire body: camelCase string keys, every absent optional key
|
|
28
|
+
# omitted. +seller+ is the seller's public id and +path+ must be an
|
|
29
|
+
# absolute path, both rejected before any request leaves the process.
|
|
30
|
+
def build_payload(seller:, path:, status: nil, duration_ms: nil, representation: nil,
|
|
31
|
+
journey_id: nil, captured_at: nil, user_agent: nil, accept: nil,
|
|
32
|
+
client_ip: nil, country: nil, referrer_host: nil)
|
|
33
|
+
Sellers.require_slug!(seller, "seller", "report_page_view")
|
|
34
|
+
unless path.is_a?(String) && path.start_with?("/")
|
|
35
|
+
raise Error.new("malformed_input", operation: "report_page_view",
|
|
36
|
+
message: "path must be a string beginning with \"/\"")
|
|
37
|
+
end
|
|
38
|
+
if !representation.nil? && !REPRESENTATIONS.include?(representation)
|
|
39
|
+
raise Error.new("malformed_input", operation: "report_page_view",
|
|
40
|
+
message: "representation must be \"html\" or \"markdown\"")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
payload = { "seller" => seller, "path" => path }
|
|
44
|
+
payload["status"] = status unless status.nil?
|
|
45
|
+
payload["durationMs"] = duration_ms unless duration_ms.nil?
|
|
46
|
+
payload["representation"] = representation unless representation.nil?
|
|
47
|
+
payload["journeyId"] = journey_id unless journey_id.nil?
|
|
48
|
+
payload["capturedAt"] = captured_at unless captured_at.nil?
|
|
49
|
+
payload["userAgent"] = user_agent unless user_agent.nil?
|
|
50
|
+
payload["accept"] = accept unless accept.nil?
|
|
51
|
+
payload["clientIp"] = client_ip unless client_ip.nil?
|
|
52
|
+
payload["country"] = country unless country.nil?
|
|
53
|
+
payload["referrerHost"] = referrer_host unless referrer_host.nil?
|
|
54
|
+
payload
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A 204 carries no body, so there is nothing to parse; a non-2xx is the
|
|
58
|
+
# only failure a well-formed beacon can hit here. Distinct from
|
|
59
|
+
# Usage.interpret_report, which requires a JSON body.
|
|
60
|
+
def interpret_report(status, _body)
|
|
61
|
+
return if (200..299).cover?(status)
|
|
62
|
+
|
|
63
|
+
raise Error.new("api_status_error", operation: "report_page_view", status: status)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/zeroclick/sellers.rb
CHANGED
|
@@ -6,8 +6,12 @@ require_relative "sellers/contracts"
|
|
|
6
6
|
require_relative "sellers/responses"
|
|
7
7
|
require_relative "sellers/verify"
|
|
8
8
|
require_relative "sellers/usage"
|
|
9
|
+
require_relative "sellers/agentify"
|
|
10
|
+
require_relative "sellers/page_views"
|
|
9
11
|
require_relative "sellers/client"
|
|
10
12
|
require_relative "sellers/middleware"
|
|
13
|
+
require_relative "sellers/middleware/agentify"
|
|
14
|
+
require_relative "sellers/middleware/page_views"
|
|
11
15
|
|
|
12
16
|
# The ZeroClick billing guard for Ruby backends.
|
|
13
17
|
#
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: zeroclick-sellers
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ZeroClick
|
|
@@ -23,11 +23,15 @@ files:
|
|
|
23
23
|
- LICENSE
|
|
24
24
|
- README.md
|
|
25
25
|
- lib/zeroclick/sellers.rb
|
|
26
|
+
- lib/zeroclick/sellers/agentify.rb
|
|
26
27
|
- lib/zeroclick/sellers/client.rb
|
|
27
28
|
- lib/zeroclick/sellers/contracts.rb
|
|
28
29
|
- lib/zeroclick/sellers/encryption.rb
|
|
29
30
|
- lib/zeroclick/sellers/errors.rb
|
|
30
31
|
- lib/zeroclick/sellers/middleware.rb
|
|
32
|
+
- lib/zeroclick/sellers/middleware/agentify.rb
|
|
33
|
+
- lib/zeroclick/sellers/middleware/page_views.rb
|
|
34
|
+
- lib/zeroclick/sellers/page_views.rb
|
|
31
35
|
- lib/zeroclick/sellers/railtie.rb
|
|
32
36
|
- lib/zeroclick/sellers/responses.rb
|
|
33
37
|
- lib/zeroclick/sellers/stateful.rb
|