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 detectionVersion in 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

Class Method Details

.ai_user_agent?(user_agent) ⇒ Boolean

Returns:

  • (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.message)
ensure
  http&.finish if http&.started?
end

.interpret_markdown(status, body, headers) ⇒ Object

------------------------------------------------------- interpret

Raises:



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.

Returns:

  • (Boolean)


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.

Raises:



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.

Returns:

  • (Boolean)


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