Module: ZeroClick::Sellers::Agentify
- Defined in:
- lib/zeroclick/sellers/agentify.rb
Overview
Agentify: serve a marketing page as agent-optimized markdown.
Detection is one pure boolean over two request headers; the conversion is a GET against ZeroClick's Agentify API, authenticated with a zc_ key carrying the agentify:convert scope. As in Usage, transport and interpretation are separate: interpret_markdown is pure, so what a response MEANS is decided in one place and tested without a socket.
Defined Under Namespace
Classes: MarkdownResult
Constant Summary collapse
- DETECTION_VERSION =
Calendar version of the detection rules below, matching the
detectionVersionin agentify-detection-vectors.json. The vector file is generated by packages/sellers-python/tests/vectors/ generate_agentify_detection_vectors.py — the authority on this contract — and a vector test pins this constant and the substring list to it, so an update cannot land here without regenerating the vectors (and vice versa). "2026-08-25.01"- AI_USER_AGENT_SUBSTRINGS =
Case-insensitive substrings that mark an AI agent's user-agent: the AI-operator crawlers, assistant fetchers, and browser agents. Classic search crawlers (googlebot, bingbot, ...) are deliberately absent — serving them different content than browsers is cloaking — and so are generic http tools (curl, python, ...), which ask for markdown via Accept when they want it.
%w[ anthropic bytespider ccbot chatgpt claude duckassistbot google-cloudvertexbot googleagent-mariner gptbot meta-externalagent meta-externalfetcher mistralai oai-searchbot openai perplexity ].freeze
- MARKDOWN_PATH =
"/v1/agentify/markdown"
Class Method Summary collapse
- .ai_user_agent?(user_agent) ⇒ Boolean
-
.get(base_url:, path:, query:, api_key:, timeout:, operation:) ⇒ Object
Returns [status, body, headers] with lowercase header names.
-
.interpret_markdown(status, body, headers) ⇒ Object
------------------------------------------------------- interpret.
-
.prefers_markdown?(accept) ⇒ Boolean
Whether text/markdown outranks text/html in an Accept header.
-
.q_value(param) ⇒ Object
The q of one already-trimmed "q=..." parameter, or nil when unusable.
- .request_headers(api_key) ⇒ Object
-
.validate_page_url!(url, operation:) ⇒ Object
Reject anything but an absolute http(s) URL before it reaches the API.
-
.wants_agent_markdown?(accept: nil, user_agent: nil) ⇒ Boolean
Whether a request should be answered with agentified markdown: the client either negotiates for it (Accept prefers text/markdown over text/html) or announces an AI agent user-agent.
Class Method Details
.ai_user_agent?(user_agent) ⇒ Boolean
120 121 122 123 124 125 |
# File 'lib/zeroclick/sellers/agentify.rb', line 120 def ai_user_agent?(user_agent) return false if user_agent.nil? || user_agent.empty? lowered = user_agent.downcase AI_USER_AGENT_SUBSTRINGS.any? { |token| lowered.include?(token) } end |
.get(base_url:, path:, query:, api_key:, timeout:, operation:) ⇒ Object
Returns [status, body, headers] with lowercase header names. Raises only api_transport_error — an HTTP status is data here, not a failure, and interpretation happens above.
176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 |
# File 'lib/zeroclick/sellers/agentify.rb', line 176 def get(base_url:, path:, query:, api_key:, timeout:, operation:) uri = URI.join(base_url, path) uri.query = URI.encode_www_form(query) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = uri.scheme == "https" # Both halves, or a server that accepts the connection and then stalls # hangs the request past the caller's budget. http.open_timeout = timeout http.read_timeout = timeout http.write_timeout = timeout request = Net::HTTP::Get.new(uri) request_headers(api_key).each { |name, value| request[name] = value } response = http.request(request) headers = response.each_header.to_h { |name, value| [name.downcase, value] } [response.code.to_i, response.body.to_s, headers] rescue *Usage::TRANSPORT_ERRORS => e raise Error.new("api_transport_error", operation: operation, cause: e.) ensure http&.finish if http&.started? end |
.interpret_markdown(status, body, headers) ⇒ Object
------------------------------------------------------- interpret
160 161 162 163 164 165 166 167 168 169 |
# File 'lib/zeroclick/sellers/agentify.rb', line 160 def interpret_markdown(status, body, headers) raise Error.new("api_status_error", operation: "fetch_agentify_markdown", status: status) unless (200..299).cover?(status) MarkdownResult.new( markdown: body, cache_control: headers["cache-control"], content_type: headers["content-type"], etag: headers["etag"] ) end |
.prefers_markdown?(accept) ⇒ Boolean
Whether text/markdown outranks text/html in an Accept header.
Agent fetchers (Claude's WebFetch and kin) send Accept: text/markdown, text/html, */*; browsers never list text/markdown.
Markdown wins only when explicitly listed with q > 0 and not outranked
by text/html (higher q, or listed first on a tie). Ported from the
platform's negotiation and pinned by the shared detection vectors: the
first entry of each media type and the first parseable q parameter of
an entry count.
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
# File 'lib/zeroclick/sellers/agentify.rb', line 88 def prefers_markdown?(accept) return false if accept.nil? || accept.empty? markdown = nil html = nil accept.downcase.split(",").each_with_index do |raw_entry, index| parts = raw_entry.strip.split(";") media_type = parts[0].to_s.strip q = 1.0 parts[1..].each do |raw_param| param = raw_param.strip next unless param.start_with?("q=") # An unparseable q contributes nothing: the default of 1 stands # unless a later q parameter parses. value = q_value(param) next if value.nil? q = value break end entry = { q: q, index: index } markdown ||= entry if media_type == "text/markdown" html ||= entry if media_type == "text/html" end return false if markdown.nil? || markdown[:q] <= 0 return true if html.nil? || html[:q] <= 0 markdown[:q] > html[:q] || (markdown[:q] == html[:q] && markdown[:index] < html[:index]) end |
.q_value(param) ⇒ Object
The q of one already-trimmed "q=..." parameter, or nil when unusable.
128 129 130 131 132 133 |
# File 'lib/zeroclick/sellers/agentify.rb', line 128 def q_value(param) value = Float(param[2..]) value.nan? || value.infinite? ? nil : value rescue ArgumentError, TypeError nil end |
.request_headers(api_key) ⇒ Object
150 151 152 153 154 155 156 |
# File 'lib/zeroclick/sellers/agentify.rb', line 150 def request_headers(api_key) { "accept" => "text/markdown", "authorization" => "Bearer #{api_key}", "user-agent" => Usage::USER_AGENT } end |
.validate_page_url!(url, operation:) ⇒ Object
Reject anything but an absolute http(s) URL before it reaches the API.
138 139 140 141 142 143 144 145 146 147 148 |
# File 'lib/zeroclick/sellers/agentify.rb', line 138 def validate_page_url!(url, operation:) parsed = begin URI.parse(url.to_s) rescue URI::InvalidURIError nil end return if parsed.is_a?(URI::HTTP) && !parsed.host.nil? && !parsed.host.empty? raise Error.new("malformed_input", operation: operation, message: "url must be an absolute http(s) URL, got #{url.inspect}") end |
.wants_agent_markdown?(accept: nil, user_agent: nil) ⇒ Boolean
Whether a request should be answered with agentified markdown: the client either negotiates for it (Accept prefers text/markdown over text/html) or announces an AI agent user-agent. A pure predicate over the two header values — absent or empty headers are simply false, never an error.
75 76 77 |
# File 'lib/zeroclick/sellers/agentify.rb', line 75 def wants_agent_markdown?(accept: nil, user_agent: nil) prefers_markdown?(accept) || ai_user_agent?(user_agent) end |