<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: bpmcginley</title>
    <description>The latest articles on DEV Community by bpmcginley (@bpmcginley).</description>
    <link>https://dev.to/bpmcginley</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3960074%2F79ba3b05-2471-4844-840a-e39d77704778.png</url>
      <title>DEV Community: bpmcginley</title>
      <link>https://dev.to/bpmcginley</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bpmcginley"/>
    <language>en</language>
    <item>
      <title>133 of 10,099 Shopify stores block an AI crawler. Six block the one ChatGPT shops with.</title>
      <dc:creator>bpmcginley</dc:creator>
      <pubDate>Sun, 06 Sep 2026 00:33:59 +0000</pubDate>
      <link>https://dev.to/bpmcginley/133-of-10099-shopify-stores-block-an-ai-crawler-six-block-the-one-chatgpt-shops-with-13ec</link>
      <guid>https://dev.to/bpmcginley/133-of-10099-shopify-stores-block-an-ai-crawler-six-block-the-one-chatgpt-shops-with-13ec</guid>
      <description>&lt;p&gt;Merchants are told two opposite things about AI crawlers: block them, because&lt;br&gt;
they take your content and give nothing back; and admit them, because that is&lt;br&gt;
how a store gets into an AI shopping answer. Both assume a decision is being&lt;br&gt;
made. We wanted to know how many stores have made one, and which way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method
&lt;/h2&gt;

&lt;p&gt;Every store in a corpus of 10,099 known Shopify storefronts has its&lt;br&gt;
&lt;code&gt;/robots.txt&lt;/code&gt; read as part of a scan. The file is parsed the way the major&lt;br&gt;
crawlers document parsing it: most specific &lt;code&gt;User-agent&lt;/code&gt; group wins, &lt;code&gt;*&lt;/code&gt; is&lt;br&gt;
the fallback, longest matching path rule wins, &lt;code&gt;Allow&lt;/code&gt; beats &lt;code&gt;Disallow&lt;/code&gt; on a&lt;br&gt;
tie. Each of twelve crawler names is asked one question: may it fetch&lt;br&gt;
&lt;code&gt;/products/&lt;/code&gt;? A store counts as blocking a crawler when the answer is no.&lt;/p&gt;

&lt;p&gt;A second reading looks at the product page for a &lt;code&gt;meta name="robots"&lt;/code&gt; tag&lt;br&gt;
carrying &lt;code&gt;noai&lt;/code&gt; or &lt;code&gt;noimageai&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Readings were taken between 29 August and 2 September 2026. Every store that&lt;br&gt;
blocked at least one crawler, or carried the tag, is one row in the CSV at&lt;br&gt;
the end. The rest of the corpus blocked nothing and is the denominator.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this cannot see.&lt;/strong&gt; A robots.txt is a request. A store can also block a&lt;br&gt;
crawler at the edge, with a bot-management rule or a firewall, and that block&lt;br&gt;
is invisible here because the scanner is not the crawler being blocked. Every&lt;br&gt;
count below is a floor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;133 of 10,099 stores block at least one crawler: 1.32%.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Crawler&lt;/th&gt;
&lt;th&gt;What it feeds&lt;/th&gt;
&lt;th&gt;Fetches at answer time&lt;/th&gt;
&lt;th&gt;Stores blocking&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CCBot&lt;/td&gt;
&lt;td&gt;Common Crawl&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;81&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPTBot&lt;/td&gt;
&lt;td&gt;OpenAI training &amp;amp; retrieval&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;77&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bytespider&lt;/td&gt;
&lt;td&gt;TikTok / Doubao&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;72&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazonbot&lt;/td&gt;
&lt;td&gt;Alexa+ / Rufus&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google-Extended&lt;/td&gt;
&lt;td&gt;AI Overviews &amp;amp; AI Mode grounding&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ClaudeBot&lt;/td&gt;
&lt;td&gt;Claude retrieval &amp;amp; citations&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;53&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Applebot-Extended&lt;/td&gt;
&lt;td&gt;Apple Intelligence&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;meta-externalagent&lt;/td&gt;
&lt;td&gt;Meta AI&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;47&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ChatGPT-User&lt;/td&gt;
&lt;td&gt;Live fetches during a ChatGPT chat&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PerplexityBot&lt;/td&gt;
&lt;td&gt;Perplexity search &amp;amp; shopping&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OAI-SearchBot&lt;/td&gt;
&lt;td&gt;ChatGPT search &amp;amp; shopping results&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Perplexity-User&lt;/td&gt;
&lt;td&gt;Live fetches when a Perplexity user asks&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Fetches at answer time" marks the four crawlers that read a page, or index&lt;br&gt;
for a search result, at the moment a person is asking. The other eight crawl&lt;br&gt;
ahead of time, to train or ground a model. The split follows each operator's&lt;br&gt;
published description of the name. The twelve are the names merchants'&lt;br&gt;
block lists actually carry, not every answer-time agent that exists;&lt;br&gt;
Anthropic's and Google's live-fetch agents are not among them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How they block
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;130 of 133 block with &lt;code&gt;Disallow: /&lt;/code&gt;. The whole site, not the product pages.
3 block &lt;code&gt;/products/&lt;/code&gt; alone.&lt;/li&gt;
&lt;li&gt;54 block exactly one crawler.&lt;/li&gt;
&lt;li&gt;28 block the same eight, verbatim: Amazonbot, Applebot-Extended, Bytespider,
CCBot, ClaudeBot, GPTBot, Google-Extended, meta-externalagent. 4 more block
those eight and others. Identical lists do not arise independently; this
one reads like a copied snippet, and it contains none of the four
answer-time crawlers.&lt;/li&gt;
&lt;li&gt;1 store blocks all twelve.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Training or answering
&lt;/h2&gt;

&lt;p&gt;Of the 133 stores that block anything, 120 block only training and grounding&lt;br&gt;
crawlers. 13 block at least one crawler that fetches at answer time; 1 of&lt;br&gt;
those blocks only answer-time crawlers, 12 block both kinds.&lt;/p&gt;

&lt;p&gt;The clearest pair is OpenAI's. &lt;code&gt;GPTBot&lt;/code&gt; trains; &lt;code&gt;OAI-SearchBot&lt;/code&gt; is what&lt;br&gt;
ChatGPT's search and shopping results are built from. 77 stores block the&lt;br&gt;
first. 6 block the second, and every one of those 6 also blocks the first.&lt;br&gt;
The reverse, keep the shopping crawler out and let the training crawler in,&lt;br&gt;
happens on 0 stores. No store in the corpus has decided to stay out of AI shopping answers.&lt;br&gt;
The stores that are out of them by robots.txt are there because a copied&lt;br&gt;
training opt-out happened to include the name.&lt;/p&gt;

&lt;h2&gt;
  
  
  The noai tag
&lt;/h2&gt;

&lt;p&gt;8 stores carry a &lt;code&gt;noai&lt;/code&gt; or &lt;code&gt;noimageai&lt;/code&gt; meta robots tag on their product page.&lt;br&gt;
6 of the 8 block nothing at all in robots.txt. By their names, six of the eight are&lt;br&gt;
musicians' merchandise stores, which again looks like one template rather&lt;br&gt;
than eight decisions. The tag is a separate mechanism and this study makes no&lt;br&gt;
claim about which crawlers honour it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does and does not mean
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Blocking AI crawlers is rare on Shopify.&lt;/strong&gt; 98.7% of stores block none of&lt;br&gt;
the twelve by robots.txt. Where it happens it is mostly a whole-site rule&lt;br&gt;
aimed at training crawlers, and mostly one list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The shopping crawlers are almost never blocked.&lt;/strong&gt; 13 stores in 10,099 block&lt;br&gt;
any of the four answer-time crawlers we checked. For the rest, whatever stands between&lt;br&gt;
the store and an AI shopping answer, it is not robots.txt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is not a recommendation.&lt;/strong&gt; A merchant who does not want their catalogue&lt;br&gt;
in a training set has a reason, and a copied list that does the job is a&lt;br&gt;
fine way to act on it. The finding is narrower: the list most of them copied&lt;br&gt;
was written before the answer-time crawlers existed, so a store using it has&lt;br&gt;
made a decision about training and, without noticing, none about shopping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is a floor.&lt;/strong&gt; Edge blocks are real and this cannot count them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://EXAMPLE.com/robots.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Find the &lt;code&gt;User-agent:&lt;/code&gt; group that names the crawler; if none does, the &lt;code&gt;*&lt;/code&gt;&lt;br&gt;
group applies. Under that group, the longest rule matching &lt;code&gt;/products/&lt;/code&gt;&lt;br&gt;
decides, and &lt;code&gt;Disallow: /&lt;/code&gt; matches everything.&lt;/p&gt;

&lt;p&gt;All 139 rows, one per store, are in&lt;br&gt;
&lt;a href="https://shelfglance.com/research/ai-crawler-blocking.csv" rel="noopener noreferrer"&gt;ai-crawler-blocking.csv&lt;/a&gt;:&lt;br&gt;
host, grade, date read, one column per crawler, whether the noai tag was&lt;br&gt;
present, and whether the rules were whole-site or product pages only. Every&lt;br&gt;
row names a domain whose robots.txt you can read yourself.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;br&gt;
&lt;a href="https://shelfglance.com/research/ai-crawler-blocking" rel="noopener noreferrer"&gt;shelfglance.com/research/ai-crawler-blocking&lt;/a&gt;,&lt;br&gt;
where the corpus of 10,099 scanned storefronts lives.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>ai</category>
      <category>seo</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Shopify's agent-commerce category filter doesn't filter. We checked 190 stores.</title>
      <dc:creator>bpmcginley</dc:creator>
      <pubDate>Wed, 02 Sep 2026 15:15:18 +0000</pubDate>
      <link>https://dev.to/bpmcginley/shopifys-agent-commerce-category-filter-doesnt-filter-we-checked-190-stores-37al</link>
      <guid>https://dev.to/bpmcginley/shopifys-agent-commerce-category-filter-doesnt-filter-we-checked-190-stores-37al</guid>
      <description>&lt;p&gt;Since 2026 every Shopify storefront answers an agent-commerce endpoint at&lt;br&gt;
&lt;code&gt;POST /api/ucp/mcp&lt;/code&gt;, advertised at &lt;code&gt;GET /.well-known/ucp&lt;/code&gt;. Merchants did not&lt;br&gt;
turn it on and it is not in their admin. It speaks the Universal Commerce&lt;br&gt;
Protocol over JSON-RPC, and the tool that matters is &lt;code&gt;search_catalog&lt;/code&gt;: an AI&lt;br&gt;
shopping agent asks a store for its catalogue and gets structured product data&lt;br&gt;
back - integer prices in minor units with a currency, variants, SKUs, canonical&lt;br&gt;
URLs, and a Shopify taxonomy category per product.&lt;/p&gt;

&lt;p&gt;Fetch the tool list from any store and &lt;code&gt;search_catalog&lt;/code&gt; declares&lt;br&gt;
&lt;code&gt;catalog.filters.categories&lt;/code&gt;, an array of strings documented as "category&lt;br&gt;
filters combined with OR logic", next to &lt;code&gt;catalog.filters.price.{min,max}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So an agent should be able to ask for running shoes and get running shoes. We&lt;br&gt;
were about to write a paragraph about what it costs a merchant to leave the&lt;br&gt;
category field blank. Then we tried it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Method
&lt;/h2&gt;

&lt;p&gt;200 stores, drawn deterministically from a corpus of 10,099 known Shopify&lt;br&gt;
storefronts: sort the hostnames, take every Nth. Reproducible, so nobody has&lt;br&gt;
to take "we picked 200 stores" on trust. Run on 2 September 2026. Every store&lt;br&gt;
got the same five calls, 10 products requested each time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Call&lt;/th&gt;
&lt;th&gt;Filter sent&lt;/th&gt;
&lt;th&gt;A working filter would&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Control&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;return products&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Impossible category&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gid://shopify/TaxonomyCategory/zz-99-99-99&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;return nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;The store's own category&lt;/td&gt;
&lt;td&gt;a category the control's products carry&lt;/td&gt;
&lt;td&gt;return at least that product&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Same, unwrapped&lt;/td&gt;
&lt;td&gt;the bare id without &lt;code&gt;gid://...&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the other form an agent would try&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Price control&lt;/td&gt;
&lt;td&gt;&lt;code&gt;price.max = 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;return nothing - nothing costs a cent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Calls 2 and 3 only mean something together. Call 2 alone cannot distinguish&lt;br&gt;
"the filter is ignored" from "the filter rejects everything". Those are&lt;br&gt;
opposite findings, and both happen.&lt;/p&gt;

&lt;p&gt;The query matters more than it looks. Generic words ("gift", "set", "new")&lt;br&gt;
surface a catalogue's odd corners rather than its catalogue, and produce&lt;br&gt;
numbers that are measured honestly and still wrong. Every query here is three&lt;br&gt;
words from one of the store's own product titles, read from its public&lt;br&gt;
&lt;code&gt;/products.json&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;190 stores answered.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the store did with &lt;code&gt;filters.categories&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Stores&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ignored it - the impossible category returned the full unfiltered set&lt;/td&gt;
&lt;td&gt;186&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rejected everything - zero for every value, including its own products' category&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filtered correctly - impossible category empty, own category not&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Of the 200 sampled, 10 could not be read: eight served no product feed to&lt;br&gt;
build a query from, two matched nothing for their own product title.&lt;/p&gt;

&lt;p&gt;177 of the 190 returned at least one product carrying a taxonomy category, so&lt;br&gt;
this is not a missing-data story. The category is there. The filter does not&lt;br&gt;
use it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The control is the whole reason this is publishable
&lt;/h2&gt;

&lt;p&gt;A null result is worth nothing unless you can show the request worked. The&lt;br&gt;
control is &lt;code&gt;price.max&lt;/code&gt;, sent in the same envelope to the same store with one&lt;br&gt;
field changed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On 150 of 190 stores, &lt;code&gt;price.max&lt;/code&gt; of one cent correctly returned nothing.&lt;/li&gt;
&lt;li&gt;On 148 of those, the very same request that honoured price ignored the category.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One field in &lt;code&gt;filters&lt;/code&gt; moves the result and the other does not, in the same&lt;br&gt;
call, on the same store. If nothing had moved, the right conclusion would have&lt;br&gt;
been that we were sending it wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug we shipped first
&lt;/h2&gt;

&lt;p&gt;The first full run reported 177 stores refusing the request outright, and it&lt;br&gt;
was about to be written up as a discovery. It was our bug.&lt;/p&gt;

&lt;p&gt;A product comes back with&lt;br&gt;
&lt;code&gt;categories: [{"value": "gid://shopify/TaxonomyCategory/hb-3-2-1-1", "taxonomy": "shopify"}]&lt;/code&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;objects. &lt;code&gt;filters.categories&lt;/code&gt; is declared as an array of strings. We were
passing the object straight back in, violating the endpoint's own schema, and
calling its entirely correct refusal a finding about Shopify.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Taking &lt;code&gt;.value&lt;/code&gt; fixed it, and call 4 exists because of it: if neither the&lt;br&gt;
wrapped identifier nor the bare one narrows anything, "you sent it wrong"&lt;br&gt;
stops being available as an explanation. This is the exact failure this kind&lt;br&gt;
of post usually ships with, and the only defence is publishing the rows.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it does and does not mean
&lt;/h2&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; mean an uncategorised product is invisible to agents. Nothing&lt;br&gt;
is being filtered out of anything, so a blank category excludes you from&lt;br&gt;
nothing. That was the sentence we expected to write, and it is false.&lt;/p&gt;

&lt;p&gt;It &lt;strong&gt;does&lt;/strong&gt; mean an agent cannot narrow a catalogue search by category today,&lt;br&gt;
whatever the schema says, on any of the 190 stores we could read.&lt;/p&gt;

&lt;p&gt;It is not a complaint about Shopify. UCP shipped in 2026; a schema arriving&lt;br&gt;
before every part of its behaviour is ordinary for a young protocol doing a&lt;br&gt;
hard thing. The lesson is older than agentic commerce: test what an endpoint&lt;br&gt;
does rather than reading what it declares.&lt;/p&gt;
&lt;h2&gt;
  
  
  Reproduce it in one call
&lt;/h2&gt;

&lt;p&gt;Pick any Shopify domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://EXAMPLE.com/api/ucp/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
    "name":"search_catalog",
    "arguments":{"catalog":{"query":"YOUR QUERY","filters":{
      "categories":["gid://shopify/TaxonomyCategory/zz-99-99-99"]}}}}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with and without the &lt;code&gt;filters&lt;/code&gt; block and compare the counts. Calling&lt;br&gt;
the endpoint requires serving a UCP platform profile the store can fetch;&lt;br&gt;
ours declares empty &lt;code&gt;payment_handlers&lt;/code&gt;, because a scanner takes no payments.&lt;/p&gt;

&lt;p&gt;All 190 readings, one row per store - domain, query, products returned for&lt;br&gt;
each of the five calls, and the verdict - are in&lt;br&gt;
&lt;a href="https://shelfglance.com/shopify-ucp/filter-survey.csv" rel="noopener noreferrer"&gt;filter-survey.csv&lt;/a&gt;.&lt;br&gt;
Every row names a domain you can re-run yourself, which is the point of&lt;br&gt;
publishing it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;br&gt;
&lt;a href="https://shelfglance.com/research/ucp-category-filter" rel="noopener noreferrer"&gt;shelfglance.com/research/ucp-category-filter&lt;/a&gt;,&lt;br&gt;
where the corpus of 10,099 scanned storefronts lives.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>api</category>
      <category>ai</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>How to Generate Link Previews Like Slack (Without the Edge-Case Hell)</title>
      <dc:creator>bpmcginley</dc:creator>
      <pubDate>Sat, 30 May 2026 14:31:38 +0000</pubDate>
      <link>https://dev.to/bpmcginley/how-to-generate-link-previews-like-slack-without-the-edge-case-hell-b0c</link>
      <guid>https://dev.to/bpmcginley/how-to-generate-link-previews-like-slack-without-the-edge-case-hell-b0c</guid>
      <description>&lt;p&gt;You've seen it a thousand times: you paste a URL into Slack, Discord, or iMessage and it blooms into a tidy card with a title, an image, and a description. It's one of the highest-trust little UI elements you can add to a chat app, a comment box, a CMS, or a bookmarking tool. A bare URL looks like spam. An unfurled link looks legit — and gets clicked.&lt;/p&gt;

&lt;p&gt;So you decide to build it. How hard can it be? You fetch the page, grab a few meta tags, done by lunch.&lt;/p&gt;

&lt;p&gt;Then reality shows up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a link preview actually is
&lt;/h2&gt;

&lt;p&gt;The card is built from &lt;strong&gt;Open Graph&lt;/strong&gt; tags in the page's &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;​&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:title"&lt;/span&gt;       &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"The Verge"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Technology, science and culture."&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt;       &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://.../cover.png"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
​```



Read those, render a card. Simple — in the demo. The pain is everything *between* "fetch the page" and "read the tags."

## The five things that break your weekend scraper

1. **Redirects.** `t.co`, `bit.ly`, and `http → https` mean the URL you were handed isn't the page you parse. You have to follow them.
2. **Missing tags.** Tons of sites have no `og:*` at all. You need a fallback chain: Open Graph → Twitter Card → `&lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;`/`meta description` → first `&lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;`/`&lt;span class="nt"&gt;&amp;lt;img&amp;gt;&lt;/span&gt;`.
3. **Relative image URLs.** `og:image` is often `/img/cover.png`, not a full URL. Resolve it against the final page URL or the image just won't load.
4. **Timeouts and giant pages.** A slow or multi-megabyte page will hang your request or eat your memory. You need a hard timeout and a byte cap.
5. **SSRF — the dangerous one.** If users submit the URLs, an attacker can point you at `http://169.254.169.254` (cloud metadata) or `http://localhost` to reach internal services. You must block private/loopback/link-local IPs — on **every** redirect hop, not just the first.

That last one is why "just scrape it yourself" quietly becomes a security review. Link-preview features are a classic SSRF vector in real apps.

## The shortcut: one request, clean JSON

If you'd rather not own all of that, hand the URL to a service that already has, and get structured data back:

​

```javascript
const res = await fetch(
  "https://link-preview14.p.rapidapi.com/preview?url=" +
    encodeURIComponent(targetUrl),
  { headers: {
      "X-RapidAPI-Key": "YOUR_KEY",
      "X-RapidAPI-Host": "link-preview14.p.rapidapi.com",
  } },
);
const preview = await res.json();
​```



You get back:

​

```json
{
  "resolvedUrl": "https://www.theverge.com/",
  "title": "The Verge",
  "description": "The Verge is about technology and how it makes us feel.",
  "image": "https://www.theverge.com/static-assets/og-image.png",
  "favicon": "https://www.theverge.com/favicon.ico",
  "siteName": "The Verge",
  "themeColor": "#5200ff"
}
​```



Redirects followed, relative URLs resolved, fallbacks applied, private IPs blocked, responses cached. Every missing field comes back as `null`, so the shape never changes and your render code stays boring (the good kind).

## When to build vs. when to buy

Building it yourself is totally reasonable if you control the input — a fixed list of trusted URLs, internal pages, that kind of thing. The moment URLs are **user-submitted**, the edge cases and the SSRF surface make a purpose-built endpoint the faster, safer call.

If you want to skip the weekend, the API above is on RapidAPI with a free tier (no card): **[Link Preview API](https://rapidapi.com/bpmcginley/api/link-preview14)**. Full write-up with the field reference is [here](https://linkpreviewapi.onrender.com/blog/how-to-generate-link-previews.html).

What edge case bit you hardest building link previews? I'll add the bad ones to the article.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
