<?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: Ohad Farkash</title>
    <description>The latest articles on DEV Community by Ohad Farkash (@ohadfarkash).</description>
    <link>https://dev.to/ohadfarkash</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%2F4089718%2F8da40890-910d-4279-966d-5b2a14b7254c.png</url>
      <title>DEV Community: Ohad Farkash</title>
      <link>https://dev.to/ohadfarkash</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ohadfarkash"/>
    <language>en</language>
    <item>
      <title>Shopping search translation is mostly query expansion: what 941 real queries showed</title>
      <dc:creator>Ohad Farkash</dc:creator>
      <pubDate>Thu, 01 Oct 2026 16:22:27 +0000</pubDate>
      <link>https://dev.to/ohadfarkash/shopping-search-translation-is-mostly-query-expansion-what-941-real-queries-showed-31d9</link>
      <guid>https://dev.to/ohadfarkash/shopping-search-translation-is-mostly-query-expansion-what-941-real-queries-showed-31d9</guid>
      <description>&lt;p&gt;I run &lt;a href="https://onefindme.com/en/?utm_source=devto&amp;amp;utm_medium=article" rel="noopener noreferrer"&gt;OneFindMe&lt;/a&gt;, a search front-end for AliExpress that takes a query in Hebrew, Arabic, Russian, German and eight other languages and turns it into the short English query the marketplace actually indexes. I always described that step as "translation". After exporting 941 of those query pairs and looking at them properly, I don't think it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data
&lt;/h2&gt;

&lt;p&gt;The pairs come from the engine's keyword map: hand-curated mappings, mappings the engine learned and kept, and a sample of real searches. Every row is a shopper's query, its language, and the English query that retrieved the right products. It is heavily Hebrew (862 of 941 rows), so treat the other languages as examples, not statistics.&lt;/p&gt;

&lt;p&gt;It is public under CC BY 4.0: &lt;a href="https://www.kaggle.com/datasets/onefindme/multilingual-shopping-queries" rel="noopener noreferrer"&gt;Kaggle&lt;/a&gt;, &lt;a href="https://huggingface.co/datasets/fufu1976/multilingual-shopping-queries" rel="noopener noreferrer"&gt;Hugging Face&lt;/a&gt; and &lt;a href="https://doi.org/10.5281/zenodo.22930843" rel="noopener noreferrer"&gt;Zenodo (DOI 10.5281/zenodo.22930843)&lt;/a&gt;. The full analysis is a &lt;a href="https://www.kaggle.com/code/onefindme/what-shoppers-type-vs-what-marketplaces-index" rel="noopener noreferrer"&gt;Kaggle notebook&lt;/a&gt;; every number below comes from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: the query usually gets longer
&lt;/h2&gt;

&lt;p&gt;If this were translation, you would expect the English side to be about as long as the source, or shorter once the filler is gone. For Hebrew:&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;share of queries&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;English query has &lt;strong&gt;more&lt;/strong&gt; words&lt;/td&gt;
&lt;td&gt;59.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;same number of words&lt;/td&gt;
&lt;td&gt;30.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;fewer&lt;/strong&gt; words&lt;/td&gt;
&lt;td&gt;10.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Average length goes from 2.35 words to 3.10. The rewrite adds information far more often than it removes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: what gets added is who it is for, and what form it comes in
&lt;/h2&gt;

&lt;p&gt;The most frequent words on the English side are not product nouns. They are &lt;strong&gt;audience&lt;/strong&gt; words and &lt;strong&gt;bundle&lt;/strong&gt; words:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;37% of English queries contain an audience word (women, men, baby, kids…)&lt;/li&gt;
&lt;li&gt;15% contain a bundle word (set, kit, pack, pcs)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some curated pairs (glosses in brackets):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;shopper typed&lt;/th&gt;
&lt;th&gt;meaning&lt;/th&gt;
&lt;th&gt;marketplace query&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;חצאית&lt;/td&gt;
&lt;td&gt;skirt&lt;/td&gt;
&lt;td&gt;&lt;code&gt;women skirt&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;טיולון לתינוק&lt;/td&gt;
&lt;td&gt;stroller for a baby&lt;/td&gt;
&lt;td&gt;&lt;code&gt;baby lightweight stroller&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;שייקר&lt;/td&gt;
&lt;td&gt;shaker&lt;/td&gt;
&lt;td&gt;&lt;code&gt;protein shaker bottle mixer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;מגהץ&lt;/td&gt;
&lt;td&gt;iron&lt;/td&gt;
&lt;td&gt;&lt;code&gt;garment steamer handheld clothes&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;דיפ פאודר&lt;/td&gt;
&lt;td&gt;dip powder&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dip powder nail kit&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;נעלי טרום הליכה&lt;/td&gt;
&lt;td&gt;pre-walking shoes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;baby prewalker shoes soft sole&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Hebrew for "skirt" doesn't need the word "women"; context and grammatical gender carry it. A listing title on AliExpress does need it, because that is how sellers write titles and how the marketplace's search matches them. The same goes for "shaker": in Hebrew, in a shopping context, it means the gym bottle. The English word alone is ambiguous.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: when it gets shorter, it is dropping noise
&lt;/h2&gt;

&lt;p&gt;The 10% that shrink are mostly shoppers putting &lt;em&gt;conditions&lt;/em&gt; into the query:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;shopper typed&lt;/th&gt;
&lt;th&gt;meaning&lt;/th&gt;
&lt;th&gt;marketplace query&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;כפכפי נשים מותג משלוח חינם&lt;/td&gt;
&lt;td&gt;women's slippers brand free shipping&lt;/td&gt;
&lt;td&gt;&lt;code&gt;women slippers brand&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;מצעים תקציב נמוך&lt;/td&gt;
&lt;td&gt;bedding, low budget&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bedding set&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Free shipping" and "low budget" are filters, not product words, and putting them in the search string hurts more than it helps.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed my mind about
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It is expansion, not translation.&lt;/strong&gt; A plain MT model gives a correct "skirt" and drops the word that listing titles lead with. The target isn't the most faithful English sentence. It is the query in the vocabulary the listings are written in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evaluate on retrieval, not fluency.&lt;/strong&gt; BLEU against this target column would reward the right words, but the real test is whether the query returns the intended products. A fluent but literal translation can score fine on text overlap and still retrieve the wrong things.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Curate the head, generate the tail.&lt;/strong&gt; The curated rows are the high-traffic queries, checked by hand. The long tail is rewritten by a language model with instructions to produce marketplace vocabulary, and the results are cached so the next shopper with the same query gets an instant answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;p&gt;The data is mostly Hebrew. Rows marked &lt;code&gt;observed&lt;/code&gt; are tagged with the language of the page the search was made on, not the language of the text, so a few carry a tag that doesn't match their script. I haven't measured retrieval quality per row here, only the shape of the rewrites.&lt;/p&gt;

&lt;p&gt;If you build multilingual search for e-commerce, I'd be curious whether you see the same expansion pattern in your languages.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance; the numbers are computed from the dataset in the linked notebook.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nlp</category>
      <category>machinelearning</category>
      <category>datascience</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>What my blind brother taught me about making a shopping search engine work with VoiceOver</title>
      <dc:creator>Ohad Farkash</dc:creator>
      <pubDate>Sun, 27 Sep 2026 16:01:59 +0000</pubDate>
      <link>https://dev.to/ohadfarkash/what-my-blind-brother-taught-me-about-making-a-shopping-search-engine-work-with-voiceover-1npe</link>
      <guid>https://dev.to/ohadfarkash/what-my-blind-brother-taught-me-about-making-a-shopping-search-engine-work-with-voiceover-1npe</guid>
      <description>&lt;p&gt;My brother is blind. When I asked him to try the shopping search engine I'd been building, &lt;a href="https://onefindme.com/accessibility/" rel="noopener noreferrer"&gt;OneFindMe&lt;/a&gt;, with VoiceOver on his iPhone, the automated checker had already told me the site was in decent shape. The first thing he tried, voice search, did nothing at all. That sent me looking for everything else the checker couldn't see.&lt;/p&gt;

&lt;p&gt;This is what changed, what I got wrong, and the parts that were more interesting than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  axe is about a third of the job
&lt;/h2&gt;

&lt;p&gt;I started where everyone starts: axe-core against the home page and a results page, desktop and mobile. It found real problems. Two sort/filter &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt;s had no accessible name, 130 to 250 grey 7px labels failed contrast, and there was no &lt;code&gt;main&lt;/code&gt; landmark. All fixed, and the scan went to zero.&lt;/p&gt;

&lt;p&gt;Then I went through the site the way my brother uses it, keyboard and screen reader only, and a different list appeared, none of it visible to axe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pop-up windows that didn't exist for a screen reader.&lt;/strong&gt; Favourites, price alerts and "similar products" were plain &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;s. They opened on screen, focus stayed behind them, and VoiceOver said nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Results that arrived in silence.&lt;/strong&gt; A sighted user sees the grid fill in. A blind user had no idea the search had finished.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The wrong things first.&lt;/strong&gt; The "trending" rail sits above the results in the DOM, so a screen reader reading from the top met a row of unrelated bestsellers before the thing you searched for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dead buttons.&lt;/strong&gt; A ☰ menu button, "Home" and "History" in the bottom bar: visible, focusable, and wired to nothing. For a sighted user that's a small annoyance. For a screen reader user it's a trap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emoji read aloud.&lt;/strong&gt; "Fire. Daily deals." "Bell. Price alerts." Every one of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dark-mode bugs that hit everyone.&lt;/strong&gt; A white "Load more" button with light text on it (1.2:1), and a help card with the same problem. I'd only ever looked at dark mode on the results page.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One sentence per product
&lt;/h2&gt;

&lt;p&gt;A product card is a link wrapping an image, a badge, two prices, a title, a rating and an order count. Read in DOM order, that came out as "Top pick, minus sixty-six percent, 15.04, 30.70, Baby cup…". I replaced it with one &lt;code&gt;aria-label&lt;/code&gt; on the link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a11yLabel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="nx"&gt;shownTitle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sym&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;discount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;` (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;was&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sym&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;original_price&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rating&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rating&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rating&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;of5&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;orders&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLocaleString&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ordersLabel&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;free_shipping&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;freeShip&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;
&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Save and Share buttons now say &lt;em&gt;which&lt;/em&gt; product they belong to. Forty identical "Save" buttons in a row are useless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Announcing results
&lt;/h2&gt;

&lt;p&gt;A polite live region, plus one detail that took me a while to find: a screen reader only speaks a live region when its text &lt;em&gt;changes&lt;/em&gt;. Two identical searches in a row were silent. Clearing the region first and filling it on the next tick fixes that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;announce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After a search I also move focus to the results heading with &lt;code&gt;focus({ preventScroll: true })&lt;/code&gt;. The reading position jumps to the results, and a sighted visitor sees nothing move. I skip this while focus is in the filter bar, because arrowing through a &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt; fires &lt;code&gt;change&lt;/code&gt; on every step.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Describe this product"
&lt;/h2&gt;

&lt;p&gt;This is the part I'm proudest of, and the one I most expected to get wrong.&lt;/p&gt;

&lt;p&gt;On AliExpress the titles are keyword piles ("2026 Summer Men's Dad Sneakers Breathable…"). The real information is in the photo, which a blind shopper can't see. So every result now has a button, visually hidden but reachable by keyboard and screen reader, that sends the product photo to a vision model. The model describes what is actually shown, in the page's language.&lt;/p&gt;

&lt;p&gt;Some notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model choice mattered more than I thought.&lt;/strong&gt; I compared Haiku, Sonnet and Opus on the same photos in Hebrew. Haiku invented parts that weren't there. For someone who can't check the photo, a confident wrong description is worse than none. Sonnet was as accurate as Opus and about two seconds faster, which matters when someone is waiting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The prompt says what not to do.&lt;/strong&gt; Describe only what is visible. Never invent sizes, brands or specifications. If unsure what a part is, leave it out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache per product and language.&lt;/strong&gt; Each product is paid for once, about 0.6 cents. The second request is instant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allowlist the image host.&lt;/strong&gt; The endpoint only accepts AliExpress image URLs. Otherwise it's a free vision API on my key. The first version rejected &lt;em&gt;every&lt;/em&gt; real result, because result cards load images through my own &lt;code&gt;/img?url=&lt;/code&gt; proxy. It now unwraps that.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Voice search and VoiceOver
&lt;/h2&gt;

&lt;p&gt;Two surprises:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Don't announce "listening".&lt;/strong&gt; My first instinct was to have the page say "Listening, speak now". But VoiceOver would read that text out loud while the microphone is open, and the recogniser would happily transcribe it as your search. So the page stays silent while listening, and only speaks when something goes wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iOS fails silently.&lt;/strong&gt; When speech recognition isn't allowed (for example with Dictation turned off), Safari's recogniser throws an error and the page just went quiet. Every failure path now says what happened and offers the keyboard's own dictation microphone, which works in every browser.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why I built a menu instead of installing an overlay
&lt;/h2&gt;

&lt;p&gt;Accessibility overlays (the widget with the wheelchair icon) are popular, and they don't make a site accessible. Many blind users actively dislike them, because they fight with the screen reader. My brother's VoiceOver run needed nothing extra.&lt;/p&gt;

&lt;p&gt;What &lt;em&gt;is&lt;/em&gt; useful for low-vision visitors is a small, boring menu: three text sizes, high contrast, stop animations, highlight links, a readable font, and a voice-search language picker. I built my own, and it stays out of the screen reader's way.&lt;/p&gt;

&lt;p&gt;Two gotchas from that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The site is laid out in px&lt;/strong&gt;, so changing &lt;code&gt;font-size&lt;/code&gt; did nothing. &lt;code&gt;body.style.zoom&lt;/code&gt; does what browser zoom does: it enlarges and reflows, with no horizontal scroll on a 390px screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One page monkey-patched &lt;code&gt;document.getElementById&lt;/code&gt;.&lt;/strong&gt; For missing ids it returned a truthy no-op stub. Every "does my element already exist?" check said yes, so the menu, the live region and the stylesheet were never created. That page now gets a &lt;code&gt;querySelector&lt;/code&gt;-based lookup.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;axe: zero violations on the home page in Hebrew, English and Arabic (light and dark), and on results pages in Hebrew and English (desktop and mobile).&lt;/li&gt;
&lt;li&gt;Keyboard: skip link, visible focus everywhere, dialogs that trap focus and return it on Escape, background &lt;code&gt;inert&lt;/code&gt; while a dialog is open.&lt;/li&gt;
&lt;li&gt;Real use: my brother, with VoiceOver, on his phone. His verdict after the fixes: everything is accessible the way it should be. He's still using it and sending notes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's in beta, and some product titles still arrive in English because sellers write them that way. That's exactly what the describe button is for.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: OneFindMe is free, with no signup, and funded by affiliate commissions from the stores, at no extra cost to buyers. I wrote this with help from an AI assistant; the code, the measurements and the testing with my brother are real.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>ai</category>
    </item>
    <item>
      <title>My demo videos found 5 bugs my tests didn't: 13-language product demos from real screenshots</title>
      <dc:creator>Ohad Farkash</dc:creator>
      <pubDate>Fri, 25 Sep 2026 17:41:48 +0000</pubDate>
      <link>https://dev.to/ohadfarkash/my-demo-videos-found-5-bugs-my-tests-didnt-13-language-product-demos-from-real-screenshots-1a6k</link>
      <guid>https://dev.to/ohadfarkash/my-demo-videos-found-5-bugs-my-tests-didnt-13-language-product-demos-from-real-screenshots-1a6k</guid>
      <description>&lt;p&gt;I run a small search engine that sits in front of AliExpress: you describe a product in your own language, typed or spoken, and it turns that into the short English keywords the marketplace actually matches on. The site has pages in 12 languages and understands queries in 21.&lt;/p&gt;

&lt;p&gt;I wanted a short demo video for each language. Recording 13 screen captures by hand, on a phone, from 13 "countries", was not going to happen, so I generated them from the live site instead. The videos turned out fine. The more useful result was that making them exposed five bugs that my checks had never caught.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/2qjGN7GBHq8" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;p&gt;Four steps, all Python:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Capture.&lt;/strong&gt; Headless Chrome through Selenium, with mobile emulation at 412 px wide and a device pixel ratio of 2. For each query the script loads the real search URL, waits until the product grid has real cards, and saves one tall screenshot plus the positions of the elements it needs later: the search box, the results heading, the store filter bar and the voice button.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pretend to be somewhere else.&lt;/strong&gt; The site decides the shipping country and currency from Cloudflare's &lt;code&gt;/cdn-cgi/trace&lt;/code&gt; endpoint, fetched with a synchronous XHR. I didn't want a German video showing Israeli shekels, so before the page loads the script patches &lt;code&gt;XMLHttpRequest&lt;/code&gt; to answer that one request with &lt;code&gt;loc=DE&lt;/code&gt;:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;driver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute_cdp_cmd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Page.addScriptToEvaluateOnNewDocument&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
  (() =&amp;gt; { const O = XMLHttpRequest.prototype.open, S = XMLHttpRequest.prototype.send;
    XMLHttpRequest.prototype.open = function (m, u) { this.__trace = String(u).includes(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/cdn-cgi/trace&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;); return O.apply(this, arguments); };
    XMLHttpRequest.prototype.send = function () {
      if (!this.__trace) return S.apply(this, arguments);
      Object.defineProperty(this, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, { value: 200 });
      Object.defineProperty(this, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;readyState&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, { value: 4 });
      Object.defineProperty(this, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;responseText&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, { value: &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;fl=1\nloc=DE\n&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; });
    }; })();&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;r"""&lt;/code&gt;. My first version was a normal string, Python turned &lt;code&gt;\n&lt;/code&gt; into a real newline inside a JavaScript string literal, the script died silently, and every "German" capture came back in shekels.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Compose.&lt;/strong&gt; PIL draws each frame: a phone frame on the left that scrolls from the typed query down to the results, and the query and a caption on the right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encode.&lt;/strong&gt; &lt;code&gt;imageio-ffmpeg&lt;/code&gt; pipes raw frames into libx264. Set &lt;code&gt;macro_block_size=1&lt;/code&gt;, or it quietly pads 1080 to 1088 and stretches everything.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A second script reuses the same screenshots for vertical 1080×1920 Shorts. It keeps the site address fixed at the top of the frame, because links in Shorts descriptions aren't clickable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Right-to-left and CJK without raqm
&lt;/h2&gt;

&lt;p&gt;My PIL build has no raqm, so it can't shape complex scripts on its own. Three things had to be handled by hand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hebrew:&lt;/strong&gt; reorder with &lt;code&gt;python-bidi&lt;/code&gt;. But &lt;strong&gt;wrap in logical order first, then reorder each line.&lt;/strong&gt; Wrapping an already reordered string splits it in the wrong places.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Arabic:&lt;/strong&gt; run &lt;code&gt;arabic_reshaper&lt;/code&gt; before bidi, or the letters don't join.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Japanese:&lt;/strong&gt; there are no spaces, so wrapping has to break per character. The fonts are also different: Inter has no CJK glyphs, and my first Japanese render showed the query as a row of black boxes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The five bugs the videos found
&lt;/h2&gt;

&lt;p&gt;This is why I'm writing this up. To make a video I had to &lt;em&gt;look&lt;/em&gt; at every screen, in every language, as a visitor from that country.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A Hebrew button on every page.&lt;/strong&gt; The share button next to the results heading said "שתף" (Hebrew for "share") in German, French and Polish. There were three copies of the label in the code. My grep for the Hebrew word found one. The other two were written as &lt;code&gt;\u05e9\u05ea\u05e3&lt;/code&gt; escapes, and a text search for Hebrew never sees those.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. English in the middle of German.&lt;/strong&gt; The related-search chips for German, Italian, Polish and Filipino fell back to English, so users saw suggestions like "kabellose kopfhörer &lt;strong&gt;for men&lt;/strong&gt;". The dictionaries for those languages simply didn't exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. "Baby night lamp for women."&lt;/strong&gt; The chip generator added gender suffixes to every query, so a specific search got a nonsense suggestion. It now adds suffixes only to queries of one or two words.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Everyone on the English page was American.&lt;/strong&gt; The English page is also the home of every country without its own page. Its settings mapped to US/USD, so a visitor from Tokyo or London got US shipping, dollar prices, and an eBay-US row whose "fast delivery" badge was true only in the US. The fix maps the visitor's real country to a currency. Before enabling it, I tested country-plus-currency pairs live against the search API: every non-euro currency individually, the euro countries by sampling. 36 countries are on the list now. India and Indonesia returned zero products when shipping there, so they stay on the default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Results I refused to show.&lt;/strong&gt; In Turkish the translation was right, but the marketplace's own ranking for Turkey was thin: most listings had about one order, and one smartwatch had a phone number printed on the image. In Arabic, "small cabin bag for a flight" translated correctly to "cabin travel bag" and still returned motorcycle crash bars, which is a marketplace problem, not a translation one. In both cases I cut the scene. A demo that has to hide weak results tells you something too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;p&gt;Generating marketing material from the live product works like an end-to-end test with human eyes: every language, every locale, the real page. It found five real bugs in a single afternoon, none of which any of my existing checks had flagged. The rule I'm keeping: capture, look at every frame, and cut anything you wouldn't want a user to see, instead of retouching it.&lt;/p&gt;

&lt;p&gt;The engine is at &lt;a href="https://onefindme.com/en/" rel="noopener noreferrer"&gt;onefindme.com&lt;/a&gt; if you want to try breaking it in your language.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance. The numbers and bugs come from the actual captures and fixes.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>i18n</category>
      <category>testing</category>
    </item>
    <item>
      <title>Voice search for AliExpress in 12 languages: what I learned building it</title>
      <dc:creator>Ohad Farkash</dc:creator>
      <pubDate>Mon, 21 Sep 2026 07:29:28 +0000</pubDate>
      <link>https://dev.to/ohadfarkash/voice-search-for-aliexpress-in-12-languages-what-i-learned-building-it-42i4</link>
      <guid>https://dev.to/ohadfarkash/voice-search-for-aliexpress-in-12-languages-what-i-learned-building-it-42i4</guid>
      <description>&lt;p&gt;AliExpress has a microphone in its app, and it mostly understands English. That is the whole state of voice shopping on the world's largest marketplace. So when I added voice search to &lt;a href="https://onefindme.com/en/" rel="noopener noreferrer"&gt;OneFindMe&lt;/a&gt; — a free search layer for AliExpress that works in 12 languages — I expected the hard part to be speech recognition. It wasn't. Here is what actually mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recognition was the easy part
&lt;/h2&gt;

&lt;p&gt;Modern browsers ship speech recognition (the Web Speech API). Chrome and Edge on desktop and Android, Safari on iOS — the coverage is good enough that I did not need an audio pipeline, a model, or a server. The user taps the mic, speaks in Hebrew, Arabic, Spanish or Turkish, and the browser hands back text. Free, fast, and the recognition quality in the major languages is better than I could have built.&lt;/p&gt;

&lt;p&gt;What the API does &lt;em&gt;not&lt;/em&gt; give you is a shopping query. It gives you a sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real problem: a spoken sentence is not a search
&lt;/h2&gt;

&lt;p&gt;People don't &lt;em&gt;speak&lt;/em&gt; keywords. Nobody says "wide running shoes". They say "I need running shoes but my feet are wide, something not too expensive". Typed search already suffers from this; voice makes it worse, because speech is longer, more hedged, and full of filler.&lt;/p&gt;

&lt;p&gt;So the voice path reuses the same intent layer as typed search, with one extra step: &lt;strong&gt;strip the conversational shell first.&lt;/strong&gt; "I need", "something like", "not too expensive", "for my daughter" — these are signals (price sensitivity, recipient), not query terms. The product noun and its qualifiers survive; the rest becomes filters or gets dropped. Only then does the phrase get resolved to a product concept and translated into the short, noun-first query the marketplace responds to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Language is not one switch
&lt;/h2&gt;

&lt;p&gt;Twelve languages means twelve recognizer locales, and the browser needs to be told which one &lt;em&gt;before&lt;/em&gt; the user speaks. Guessing from the site language works most of the time; letting the user override it matters for the Arabic-speaking user on an English page. And two languages needed special handling: Hebrew numbers ("שלושים שקל") and Arabic dialect words for common products, where the recognizer returns spelling variants the keyword map had never seen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The button nobody pressed
&lt;/h2&gt;

&lt;p&gt;The feature shipped with a grey microphone emoji and got almost no use for days. The fix was not technical: a labelled orange button that says "voice search" in the user's language. Usage appeared immediately. If you build voice into anything, label it in words.&lt;/p&gt;

&lt;h2&gt;
  
  
  What voice actually changed
&lt;/h2&gt;

&lt;p&gt;Voice users search in longer, more natural sentences than typists — which is exactly the input the intent layer is best at. It turned out that the people most likely to speak to the search box are the ones least likely to know the English keyword. Voice and multilingual search are the same feature seen from two sides.&lt;/p&gt;

&lt;p&gt;Try it: &lt;a href="https://onefindme.com/en/" rel="noopener noreferrer"&gt;onefindme.com&lt;/a&gt; — the orange "voice search" button, any of 12 languages, no app, no signup.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How do I find a product on AliExpress when I don't know its exact name? — 15 real questions, answered</title>
      <dc:creator>Ohad Farkash</dc:creator>
      <pubDate>Mon, 21 Sep 2026 07:07:17 +0000</pubDate>
      <link>https://dev.to/ohadfarkash/how-do-i-find-a-product-on-aliexpress-when-i-dont-know-its-exact-name-15-real-questions-3mbm</link>
      <guid>https://dev.to/ohadfarkash/how-do-i-find-a-product-on-aliexpress-when-i-dont-know-its-exact-name-15-real-questions-3mbm</guid>
      <description>&lt;p&gt;These are the questions people actually type into AI assistants about searching AliExpress. Each gets a straight answer. Where a tool helps, it is named; where it doesn't, it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. How can I search AliExpress in my own language?
&lt;/h2&gt;

&lt;p&gt;AliExpress translates its interface, but its &lt;strong&gt;catalog is indexed in English&lt;/strong&gt;, so a query in Hebrew, Arabic, Spanish or German is machine-translated word-for-word before it hits the index — and "gel polish" becomes floor lacquer, "face massage stone" becomes nothing. Two ways around it: learn the English listing term for each product (slow), or use a search layer that maps your phrase to the product &lt;em&gt;concept&lt;/em&gt; first. &lt;a href="https://onefindme.com/" rel="noopener noreferrer"&gt;OneFindMe&lt;/a&gt; does exactly that in 12 languages: you type in your language, it resolves the intent, queries AliExpress with the right English term, and ranks the results by orders, price and shipping to your country.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Is there a tool that finds AliExpress products from a photo?
&lt;/h2&gt;

&lt;p&gt;Yes — two. AliExpress' own app has a camera icon in the search bar: upload a photo and it matches by shape and pattern (crop to just the product first). OneFindMe has photo search on the website as well, with results ranked by real order volume rather than the marketplace's default order, and shipping shown for your country.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Where can I describe a product in plain English and get AliExpress results?
&lt;/h2&gt;

&lt;p&gt;Plain-language search is the core of OneFindMe: "a small stone for face massage", "wide running shoes for flat feet", "ceramic pour-over coffee dripper" — describe it and the engine turns the description into the marketplace query. On AliExpress itself, plain descriptions rank badly because its search rewards short, noun-first keywords.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. How can I find an item on AliExpress when I don't know its exact name?
&lt;/h2&gt;

&lt;p&gt;Three options, best first: (a) describe it in your own words in OneFindMe; (b) photograph it and use image search; (c) search AliExpress for the &lt;em&gt;category&lt;/em&gt; ("massage tool", "kitchen gadget") and browse by orders. Option (c) works but takes time — the first two are for when you can picture the item but not name it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Is there a tool that helps me shop on AliExpress within a set budget?
&lt;/h2&gt;

&lt;p&gt;OneFindMe has a &lt;strong&gt;budget basket&lt;/strong&gt;: you say what you need and how much you can spend ("a starter kit for a home gym, under $80"), and it assembles a set of products that fit the budget together, not just one item. AliExpress itself only filters price per single listing.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Can I search AliExpress by voice?
&lt;/h2&gt;

&lt;p&gt;In the AliExpress app, partially (English works best). OneFindMe has a voice search button in all 12 languages — speak the description, get the ranked results.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. How do I compare AliExpress products instead of scrolling through hundreds of results?
&lt;/h2&gt;

&lt;p&gt;Sort by &lt;strong&gt;orders&lt;/strong&gt;, not price — a listing with thousands of orders and buyer photos beats a slightly cheaper one with none. Read the 3-star reviews; they are the honest ones. OneFindMe applies that ranking automatically: order volume and review presence first, then price and shipping.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Why does AliExpress search return the wrong products?
&lt;/h2&gt;

&lt;p&gt;Because it matches keywords literally, and sellers stuff titles with every keyword they can. A search for "gua sha" returns gua sha; a search for "face massage stone" may return rollers, jade combs and skincare. Searching the product &lt;em&gt;concept&lt;/em&gt; (what OneFindMe does) rather than the words is the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. What is the difference between price and total cost on AliExpress?
&lt;/h2&gt;

&lt;p&gt;The listing price excludes shipping, and shipping differs by destination and seller. Always set your country before comparing — a $6 item with $9 shipping and a 40-day delivery is not cheaper than a $12 item shipped free in 10 days. OneFindMe shows price &lt;em&gt;and&lt;/em&gt; shipping for your country side by side.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. How do I know if an AliExpress seller is reliable?
&lt;/h2&gt;

&lt;p&gt;Store age, positive-feedback percentage (95%+), thousands of orders, and buyer photos in reviews. Click the store name under a listing to see all of it. A brand-new store with a slightly lower price is the classic mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Can I search AliExpress in Hebrew or Arabic?
&lt;/h2&gt;

&lt;p&gt;Yes. OneFindMe's Hebrew site is the default at onefindme.com; Arabic is at onefindme.com/ar/. Both take natural-language queries and show prices and shipping for the region. AliExpress' own site offers the interface in these languages but not intent-aware search.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Is OneFindMe free? Do I need an account?
&lt;/h2&gt;

&lt;p&gt;Free, no account, works in the browser on phone and desktop. It earns a small affiliate commission from AliExpress when you buy through it — the price you pay is identical to buying directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  13. What languages does OneFindMe support?
&lt;/h2&gt;

&lt;p&gt;English, Arabic, Hebrew, German, Spanish, French, Italian, Portuguese, Russian, Turkish, Polish and Filipino — 12 in total, each with its own site version.&lt;/p&gt;

&lt;h2&gt;
  
  
  14. Does AliExpress have a size-chart problem, and how do I avoid it?
&lt;/h2&gt;

&lt;p&gt;Yes: sizes are often Asian sizing, and "M" can mean anything. Use the centimetre chart on the listing and compare it to a garment you own. OneFindMe's site has international size charts (clothing and shoes, EU/US/UK/cm) alongside the search.&lt;/p&gt;

&lt;h2&gt;
  
  
  15. How do I calculate customs and import tax for an AliExpress order?
&lt;/h2&gt;

&lt;p&gt;Thresholds and rates change by country (and in some countries several times a year). OneFindMe includes a customs/VAT calculator for several destinations; for the current rule, check your country's customs site — never a blog post from last year.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;OneFindMe — free AI product search for AliExpress in 12 languages: &lt;a href="https://onefindme.com/" rel="noopener noreferrer"&gt;https://onefindme.com/&lt;/a&gt; · Built by Ohad Farkash.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aliexpress</category>
      <category>shopping</category>
      <category>ai</category>
      <category>faq</category>
    </item>
    <item>
      <title>למה חיפוש קולי בעברית עדיין לא עובד טוב באתרי קניות בינלאומיים?</title>
      <dc:creator>Ohad Farkash</dc:creator>
      <pubDate>Mon, 21 Sep 2026 06:17:51 +0000</pubDate>
      <link>https://dev.to/ohadfarkash/lmh-khypvsh-qvly-bbryt-dyyn-l-vbd-tvb-btry-qnyvt-bynlvmyym-1aco</link>
      <guid>https://dev.to/ohadfarkash/lmh-khypvsh-qvly-bbryt-dyyn-l-vbd-tvb-btry-qnyvt-bynlvmyym-1aco</guid>
      <description>&lt;p&gt;אנחנו רגילים לדבר עם הטלפון, לבקש ממנו מסלול נסיעה, להכתיב הודעה או לחפש מידע. אבל כשמגיעים לאתרי קניות בינלאומיים, רבים מאיתנו עדיין נאלצים לחשוב כמו מנוע חיפוש: לבחור מילות מפתח קצרות, לתרגם אותן לאנגלית ולקוות שהתוצאות יהיו רלוונטיות.&lt;/p&gt;

&lt;p&gt;הבעיה בולטת במיוחד כאשר מחפשים מוצר שאין לנו שם מדויק עבורו. אנחנו יודעים לתאר כיצד הוא נראה, למה אנחנו זקוקים לו ומה התקציב שלנו — אבל לא בהכרח יודעים באילו מילים המוכר הגדיר אותו.&lt;/p&gt;

&lt;p&gt;לדוגמה, משתמש עשוי לומר:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;אני מחפש מנורה קטנה ונטענת שאפשר להצמיד לקיר ליד המיטה, בלי לקדוח.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;זו בקשה ברורה לחלוטין לבן אדם. מנוע חיפוש מסורתי, לעומת זאת, עשוי להתקשות להבין אילו חלקים במשפט חשובים: מנורה, נטענת, הצמדה לקיר, שימוש ליד המיטה והתקנה ללא קידוח.&lt;/p&gt;

&lt;h2&gt;
  
  
  הבעיה אינה רק זיהוי הדיבור
&lt;/h2&gt;

&lt;p&gt;המרת קול לטקסט היא רק השלב הראשון. האתגר האמיתי הוא להבין את הכוונה שמאחורי המשפט.&lt;/p&gt;

&lt;p&gt;מערכת חיפוש קולית טובה צריכה לבצע כמה פעולות ברצף:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;לזהות נכון את המילים שנאמרו, כולל שמות של מותגים ומונחים לועזיים.&lt;/li&gt;
&lt;li&gt;להבין אילו מאפיינים של המוצר הכרחיים ואילו הם רק הקשר.&lt;/li&gt;
&lt;li&gt;לתרגם את הבקשה למונחים שבהם משתמשים מוכרים בינלאומיים.&lt;/li&gt;
&lt;li&gt;לחפש גם מילים נרדפות ותיאורים חלופיים.&lt;/li&gt;
&lt;li&gt;לדרג את התוצאות לפי מידת ההתאמה לבקשה המקורית.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;חיפוש אחר „תיק קטן לטיסה שנכנס מתחת למושב” אינו זהה לחיפוש המילולי אחר „small bag”. המערכת צריכה להבין שמדובר כנראה בתיק אישי לטיסה, להתחשב במידות ולהבדיל בינו לבין מזוודת יד רגילה.&lt;/p&gt;

&lt;h2&gt;
  
  
  מדוע עברית הופכת את האתגר למורכב יותר?
&lt;/h2&gt;

&lt;p&gt;באתרי מסחר בינלאומיים, שמות המוצרים והתיאורים נכתבים בדרך כלל באנגלית או מתורגמים אוטומטית משפות אחרות. התרגום אינו תמיד טבעי ולעיתים אותו מוצר מופיע תחת כמה שמות שונים.&lt;/p&gt;

&lt;p&gt;גם לעברית עצמה יש מאפיינים שמקשים על החיפוש:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;מילה אחת עשויה להופיע בצורות שונות בגלל תחיליות וסיומות.&lt;/li&gt;
&lt;li&gt;משתמשים משלבים עברית ואנגלית באותו משפט.&lt;/li&gt;
&lt;li&gt;שמות מותגים נכתבים לעיתים בעברית ולעיתים באותיות לטיניות.&lt;/li&gt;
&lt;li&gt;אנשים מתארים את השימוש במוצר במקום את שמו.&lt;/li&gt;
&lt;li&gt;שפת הדיבור שונה מהמונחים המופיעים בקטלוגים.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;משתמש עשוי לומר „כפכפים חומים עם פרווה מאחור”, בעוד שבקטלוג המוצר יופיע תחת מונחים כמו mule, clog, slipper או fur-lined shoe.&lt;/p&gt;

&lt;p&gt;תרגום מילולי של המשפט אינו מספיק. יש צורך בשכבת הבנה שמחברת בין האופן שבו אדם מתאר מוצר לבין האופן שבו המוצר מסווג בקטלוג.&lt;/p&gt;

&lt;h2&gt;
  
  
  מחיפוש של מילים לחיפוש של כוונה
&lt;/h2&gt;

&lt;p&gt;מנועי חיפוש מסורתיים מנסים למצוא התאמה בין מילות השאילתה לבין הטקסט שמופיע בדף המוצר. חיפוש מבוסס בינה מלאכותית מנסה להבין מה המשתמש באמת רוצה למצוא.&lt;/p&gt;

&lt;p&gt;במקום לפרק את הבקשה רק למילות מפתח, המערכת יכולה לזהות מאפיינים כמו:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;סוג המוצר&lt;/li&gt;
&lt;li&gt;צבע וחומר&lt;/li&gt;
&lt;li&gt;שימוש מיועד&lt;/li&gt;
&lt;li&gt;טווח מחיר&lt;/li&gt;
&lt;li&gt;קהל יעד&lt;/li&gt;
&lt;li&gt;מידות&lt;/li&gt;
&lt;li&gt;תכונות שחייבות להופיע&lt;/li&gt;
&lt;li&gt;תכונות שצריך להימנע מהן&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;אם משתמש אומר „אני צריך מתנה לילד בן עשר שאוהב חלל, עד 100 שקל”, החיפוש אינו צריך להתמקד רק במילים „ילד”, „חלל” ו„100 שקל”. עליו להבין שמדובר ברעיון למתנה, להתאים את המוצרים לקבוצת הגיל ולכבד את מגבלת התקציב.&lt;/p&gt;

&lt;h2&gt;
  
  
  למה קול מתאים במיוחד לחיפוש מוצרים?
&lt;/h2&gt;

&lt;p&gt;בשדה חיפוש רגיל אנשים נוטים לכתוב שתיים או שלוש מילים. בדיבור הם מספקים באופן טבעי יותר פרטים:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;אני מחפשת תיק שחור קטן לערב, עם רצועה ארוכה, שלא יהיה מבריק מדי.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;המידע הנוסף יכול לשפר את התוצאות — בתנאי שהמערכת מסוגלת להבין אותו. החיפוש הקולי אינו רק תחליף למקלדת; הוא מאפשר למשתמש לנסח צורך שלם במקום לנחש את מילות המפתח הנכונות.&lt;/p&gt;

&lt;p&gt;הוא עשוי להיות שימושי במיוחד בנייד, בזמן נסיעה, עבור אנשים שמתקשים להקליד או כאשר שם המוצר אינו ידוע.&lt;/p&gt;

&lt;h2&gt;
  
  
  הניסיון של OneFindMe
&lt;/h2&gt;

&lt;p&gt;במהלך העבודה על &lt;a href="https://onefindme.com/" rel="noopener noreferrer"&gt;OneFindMe&lt;/a&gt; ניסיתי להפוך את החיפוש באתרי מסחר לשיחה טבעית יותר. המשתמש יכול לתאר בעברית את המוצר שהוא רוצה, להשתמש בחיפוש קולי או להעלות תמונה, והמערכת מתרגמת את הבקשה לחיפוש מוצרים מתאים ב־AliExpress.&lt;/p&gt;

&lt;p&gt;המטרה אינה רק להמיר עברית לאנגלית. המערכת צריכה לזהות את סוג המוצר, להבין את המאפיינים החשובים וליצור שאילתות שמתאימות לאופן שבו המוצרים מתוארים בפועל.&lt;/p&gt;

&lt;p&gt;לדוגמה, הבקשה „מדף קטן למקלחת שלא צריך לקדוח בשבילו” צריכה להוביל למוצרים המוגדרים כמדפים הנצמדים באמצעות דבק או ואקום — גם אם המילים המדויקות שאמר המשתמש אינן מופיעות בכותרת המוצר.&lt;/p&gt;

&lt;h2&gt;
  
  
  עדיין קיימות מגבלות
&lt;/h2&gt;

&lt;p&gt;חיפוש קולי אינו פתרון קסם. רעשי רקע, מבטאים, שמות מותגים ומילים שמשלבות עברית ואנגלית עלולים לגרום לטעויות. גם לאחר שהדיבור זוהה נכון, המערכת עלולה לפרש באופן שגוי את הכוונה.&lt;/p&gt;

&lt;p&gt;לכן חשוב לאפשר למשתמש לראות את הבקשה שזוהתה, לערוך אותה ולהמשיך לחדד את התוצאות. שילוב נכון בין קול, טקסט ותמונה עשוי להיות יעיל יותר מהסתמכות על דרך חיפוש אחת בלבד.&lt;/p&gt;

&lt;h2&gt;
  
  
  העתיד של חיפוש המוצרים
&lt;/h2&gt;

&lt;p&gt;המעבר הגדול אינו ממקלדת למיקרופון, אלא מחיפוש המבוסס על מילות מפתח לחיפוש המבוסס על שיחה וכוונה.&lt;/p&gt;

&lt;p&gt;במקום ללמוד כיצד הקטלוג מנסח את שמות המוצרים, המשתמש יוכל פשוט להסביר מה הוא מחפש. המערכת תהיה זו שתצטרך להבין, לתרגם ולמצוא את ההתאמה.&lt;/p&gt;

&lt;p&gt;כאשר זה יעבוד היטב בעברית ובשפות נוספות, חיפוש מוצרים בינלאומי ירגיש פחות כמו פתרון חידה — ויותר כמו שיחה עם אדם שמבין מה אנחנו רוצים.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>nlp</category>
      <category>seo</category>
      <category>startup</category>
    </item>
    <item>
      <title>What I learned building a 12-language AI product search for AliExpress</title>
      <dc:creator>Ohad Farkash</dc:creator>
      <pubDate>Sun, 20 Sep 2026 11:16:33 +0000</pubDate>
      <link>https://dev.to/ohadfarkash/what-i-learned-building-a-12-language-ai-product-search-for-aliexpress-2n61</link>
      <guid>https://dev.to/ohadfarkash/what-i-learned-building-a-12-language-ai-product-search-for-aliexpress-2n61</guid>
      <description>&lt;p&gt;AliExpress has one of the largest product catalogs on the planet, but its search&lt;br&gt;
assumes you already know the exact English keyword. If a shopper can only &lt;em&gt;describe&lt;/em&gt; what they&lt;br&gt;
want — "that little stone thing for face massage" — the results collapse into noise. I spent a&lt;br&gt;
while building &lt;a href="https://onefindme.com/en/" rel="noopener noreferrer"&gt;OneFindMe&lt;/a&gt;, a free multilingual search layer that sits in front of AliExpress,&lt;br&gt;
and these were the lessons that actually moved the needle.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Translating the &lt;em&gt;intent&lt;/em&gt;, not the words
&lt;/h2&gt;

&lt;p&gt;Naive translation is a trap. "לק ג'ל" (Hebrew) machine-translates to "gel polish", which on&lt;br&gt;
AliExpress surfaces floor lacquer as often as nail products. The fix was to resolve the shopping&lt;br&gt;
&lt;em&gt;intent&lt;/em&gt; to a canonical product query first, then translate that — not translate the raw phrase.&lt;br&gt;
A small curated keyword map beat the general model for the high-traffic terms, because the model&lt;br&gt;
kept inventing plausible-but-wrong category ids.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Only the first few words of a query survive
&lt;/h2&gt;

&lt;p&gt;Long, descriptive queries rank worse than short ones on the marketplace API. The product noun has&lt;br&gt;
to lead. "comfortable running shoes for wide feet women" performs far worse than "wide running&lt;br&gt;
shoes". So the pipeline trims to the product noun + one or two qualifiers before it ever hits the&lt;br&gt;
API.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Rank by orders, not price
&lt;/h2&gt;

&lt;p&gt;The cheapest listing is almost never the best answer. Sorting candidates by real order volume,&lt;br&gt;
then lightly penalising listings with no reviews, produced results people actually clicked. Price&lt;br&gt;
is a filter, not a ranking signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Cache the translation, not the results
&lt;/h2&gt;

&lt;p&gt;Product availability changes hourly, but the translation of "wireless earbuds" into a good query&lt;br&gt;
does not. Caching the &lt;em&gt;query resolution&lt;/em&gt; (and letting unknown terms cache their translation on&lt;br&gt;
first use, so the second shopper gets an instant answer) cut latency dramatically without serving&lt;br&gt;
stale stock.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Every market is different
&lt;/h2&gt;

&lt;p&gt;The same query needs different handling per country: shipping thresholds, which categories pay,&lt;br&gt;
even modesty defaults in some regions where the marketplace's own "relevance" surfaces things a&lt;br&gt;
shopper did not ask for. Filtering the junk while never filtering a legitimate intent turned out&lt;br&gt;
to be the hardest, most locale-specific part.&lt;/p&gt;

&lt;p&gt;If you want to see the result, &lt;a href="https://onefindme.com/en/" rel="noopener noreferrer"&gt;OneFindMe&lt;/a&gt; is live and free — type what you want in any of 12&lt;br&gt;
languages and it does the translating, trimming, and ranking described above. Happy to answer&lt;br&gt;
questions about any of these in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>api</category>
    </item>
    <item>
      <title>A Green API Is Not a Working Page</title>
      <dc:creator>Ohad Farkash</dc:creator>
      <pubDate>Fri, 18 Sep 2026 11:48:19 +0000</pubDate>
      <link>https://dev.to/ohadfarkash/a-green-api-is-not-a-working-page-58m1</link>
      <guid>https://dev.to/ohadfarkash/a-green-api-is-not-a-working-page-58m1</guid>
      <description>&lt;p&gt;I spent a morning connecting a session-recording tool to &lt;a href="https://onefindme.com/en/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=green-api" rel="noopener noreferrer"&gt;OneFindMe&lt;/a&gt;, the AliExpress product search engine I run in twelve languages, expecting to learn something about user behaviour. Instead it handed me a list of JavaScript errors, and every single one turned out to be a bug that had been in production for weeks while every check I had was green.&lt;/p&gt;

&lt;p&gt;That is the part worth writing down. Not the bugs — bugs are ordinary. The fact that my entire measurement apparatus was structurally incapable of seeing them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that cost the most
&lt;/h2&gt;

&lt;p&gt;The error text was &lt;code&gt;Unexpected identifier 's'&lt;/code&gt;. Twenty-eight occurrences.&lt;/p&gt;

&lt;p&gt;The card that renders each product built its click payload like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pJson&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/'/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;#39;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/"/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;quot;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;a href="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" onclick="onProductClick('&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pJson&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;')"&amp;gt;…&amp;lt;/a&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That escaping is correct — for an HTML attribute. It is wrong for a JavaScript string, and the attribute is both.&lt;/p&gt;

&lt;p&gt;The HTML parser decodes &lt;code&gt;'&lt;/code&gt; back into a bare apostrophe &lt;strong&gt;before&lt;/strong&gt; the browser compiles the attribute as JavaScript. So for a product titled &lt;code&gt;Women's Vacation Dress&lt;/code&gt;, what the engine actually tries to compile is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;onProductClick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"id":1,"title":"Women&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="nx"&gt;Vacation&lt;/span&gt; &lt;span class="nx"&gt;Dress&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;}')
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The string ends at &lt;code&gt;Women'&lt;/code&gt;. Syntax error. The handler never compiles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HTML entity escaping does not protect a JavaScript string literal in an inline handler. Entities are decoded first.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I proved it in the live page rather than reasoning about it, with a control:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Women's Vacation Dress&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// vs "Elegant Satin Dress"&lt;/span&gt;
&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;a onclick="probeClick('&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pJson&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;')"&amp;gt;card&amp;lt;/a&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;probeCard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// apostrophe title  → handler did NOT run&lt;/span&gt;
&lt;span class="c1"&gt;// clean title       → handler ran&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I counted how often it mattered: &lt;strong&gt;9 of 24 products on one search, 14 of 42 on another.&lt;/strong&gt; Roughly a third of every result page.&lt;/p&gt;

&lt;p&gt;The damage is worth being precise about, because the obvious guess is wrong. The `` was untouched, so &lt;strong&gt;no sale was lost&lt;/strong&gt; — shoppers still reached the retailer. What broke was everything the handler did: the favourite button and the share button silently did nothing on those products, and the click never reached my analytics.&lt;/p&gt;

&lt;p&gt;Which means every click-through rate I had measured, for months, was an undercount — and I had been making product decisions on those numbers.&lt;/p&gt;

&lt;p&gt;The fix is the one you already know: get the data out of the JavaScript entirely.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`js&lt;br&gt;
&amp;lt;a href="…" data-act="open" data-p="${pJson}"&amp;gt;…&amp;lt;/a&amp;gt;&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`js&lt;br&gt;
document.addEventListener("click", (e) =&amp;gt; {&lt;br&gt;
  const el = e.target.closest("[data-act][data-p]");&lt;br&gt;
  if (!el) return;&lt;br&gt;
  dispatch(el.dataset.act, el.dataset.p);   // parser treats it as text; nothing compiles&lt;br&gt;
});&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The button that had not existed for months
&lt;/h2&gt;

&lt;p&gt;Next error: &lt;code&gt;Cannot read properties of null (reading 'classList')&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`js&lt;br&gt;
function toggleDeals() {&lt;br&gt;
  dealsActive = !dealsActive;&lt;br&gt;
  const btn = document.querySelector(".deals-btn");&lt;br&gt;
  btn.classList.add("active");     // btn is null&lt;br&gt;
  if (currentQuery) reSearch();    // never reached&lt;br&gt;
}&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.deals-btn&lt;/code&gt; did not exist anywhere on the page. It had been renamed at some point and this function never updated. It threw on line three, &lt;em&gt;before&lt;/em&gt; the line that actually did the work — so the two visible buttons that called it were completely inert.&lt;/p&gt;

&lt;p&gt;Then I opened the session recording attached to that error, and it stopped being an abstraction:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`js&lt;br&gt;
01:02  clicked "Show all"&lt;br&gt;
01:07  clicked "Show all"&lt;br&gt;
01:27  clicked "Show all"&lt;br&gt;
01:28  clicked "Show all"&lt;br&gt;
01:28  clicked "Show all"&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Five clicks in 26 seconds, inside a seven-minute session where the same person also hit "load more" nine times. That is not a confused user. That is someone who wanted to buy something, pressing a button that did nothing, until they gave up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A handler that throws leaves no trace on the server.&lt;/strong&gt; No 500, no slow query, no error log. The only signature it leaves is a human being clicking the same thing over and over — and you can only see that if something is recording the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three more, all invisible to the API
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The product rows had no CSS at all.&lt;/strong&gt; The page carried its own inline styles and had stopped loading the shared stylesheet; nobody noticed that the rules for one component never came with it. Cards rendered as full-width inline elements with uncapped images — one product per phone screen. The check that found it is one line:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`js&lt;br&gt;
[...document.styleSheets].some(s =&amp;gt;&lt;br&gt;
  [...s.cssRules].some(r =&amp;gt; (r.selectorText || "").includes("product-card")))&lt;br&gt;
// false&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then the cards collapsed to one pixel.&lt;/strong&gt; The message list is a column flexbox that overflows, and &lt;code&gt;flex-shrink&lt;/code&gt; defaults to &lt;code&gt;1&lt;/code&gt;, so every child gets squeezed to make the container fit. The images were fully downloaded and completely invisible:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`js&lt;br&gt;
card.getBoundingClientRect().height   // 1&lt;br&gt;
card.querySelector('img').naturalWidth // 480  ← the image was fine all along&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;flex-shrink: 0&lt;/code&gt; took the card from 1px to 167px.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And&lt;/strong&gt; &lt;code&gt;loading="lazy"&lt;/code&gt; never fired inside that scroll container. Nine cards sat in the viewport with zero decoded images, while the exact same URL loaded instantly when fetched directly:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`js&lt;br&gt;
new Image().src = img.src;   // loads, 480px&lt;br&gt;
img.complete                  // false, indefinitely&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Forcing &lt;code&gt;eager&lt;/code&gt; on one loaded it immediately. Lazy loading is the wrong default for a handful of thumbnails the user explicitly asked to see; I removed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I assert on now
&lt;/h2&gt;

&lt;p&gt;The common thread is not carelessness. It is that &lt;strong&gt;my assertions were about the wrong layer.&lt;/strong&gt; I was checking that the server returned the right JSON quickly, and it always did. The bugs all lived between the JSON and the pixels.&lt;/p&gt;

&lt;p&gt;So after any change that reaches a screen, I now check the rendered DOM, not the response:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;the order of nodes&lt;/strong&gt;, not just their presence — reordering a streaming response once cut one sentence into three pieces across two product rows, and every timing measurement stayed perfect while it happened;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;each element's measured box&lt;/strong&gt; — &lt;code&gt;getBoundingClientRect()&lt;/code&gt;, because "the element exists" and "the element is one pixel tall" are the same to a selector;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;whether images actually decoded&lt;/strong&gt; — &lt;code&gt;img.complete &amp;amp;&amp;amp; img.naturalWidth &amp;gt; 1&lt;/code&gt;, because a broken image and a lazy image are indistinguishable from &lt;code&gt;src&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;whether a CSS rule for the class exists at all&lt;/strong&gt;, before debugging the layout it supposedly produces;&lt;/li&gt;
&lt;li&gt;and all of it at &lt;strong&gt;a phone viewport&lt;/strong&gt;, since that is where most of the traffic is.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two of these bugs were years old. Both were found within five minutes of pointing something at the rendered page instead of the API — the same engine I described in an earlier piece about what broke when I put an LLM in front of product search, where the lesson was also that the interesting failures were in the seams rather than the model.&lt;/p&gt;

&lt;p&gt;There is a broader version of this. Backend observability got very good — traces, structured logs, percentile latency — and it is all measuring the half of the system that was not broken here. The frontend half gets a synthetic Lighthouse run on a fast laptop and, if you are lucky, an error counter nobody reads.&lt;/p&gt;

&lt;p&gt;The cheapest fix is not a new tool. It is to stop treating a green endpoint as evidence about a page, and to write one assertion that can only pass if the thing the customer sees is actually there.&lt;/p&gt;

&lt;p&gt;The engine is &lt;a href="https://onefindme.com/en/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=green-api" rel="noopener noreferrer"&gt;OneFindMe&lt;/a&gt; — free, no signup. Every bug above came out of its production logs, and it is still finding new ways to break.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>debugging</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Can multilingual AI improve product search on international marketplaces?</title>
      <dc:creator>Ohad Farkash</dc:creator>
      <pubDate>Wed, 16 Sep 2026 10:05:13 +0000</pubDate>
      <link>https://dev.to/ohadfarkash/can-multilingual-ai-improve-product-search-on-international-marketplaces-3m20</link>
      <guid>https://dev.to/ohadfarkash/can-multilingual-ai-improve-product-search-on-international-marketplaces-3m20</guid>
      <description>&lt;p&gt;International marketplaces may support several languages, but their search engines often perform much better in English.&lt;/p&gt;

&lt;p&gt;A shopper can describe exactly what they want in Spanish, French, or Arabic and still receive irrelevant results. Product titles are frequently machine-translated, overloaded with keywords, or written differently from the way real customers naturally describe products.&lt;/p&gt;

&lt;p&gt;While developing &lt;strong&gt;OneFindMe&lt;/strong&gt;, a multilingual AI product-search project for AliExpress, I noticed that translating the same query into English could significantly change the results.&lt;/p&gt;

&lt;p&gt;This led me to explore a different approach: allowing shoppers to describe a product naturally in their own language—or upload an image—and using AI to interpret the intent before searching the marketplace.&lt;/p&gt;

&lt;p&gt;I’m curious about the technical and user-experience side of this problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Should AI translate the query first, or search using semantic meaning across languages?&lt;/li&gt;
&lt;li&gt;How should a search system handle badly translated or keyword-heavy product titles?&lt;/li&gt;
&lt;li&gt;Would users prefer natural-language search, image search, or a combination of both?&lt;/li&gt;
&lt;li&gt;Have you encountered similar problems while building multilingual search systems?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’d appreciate feedback from developers who have worked with semantic search, embeddings, multilingual models, or marketplace APIs.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>nlp</category>
      <category>search</category>
    </item>
    <item>
      <title>I Asked AI to Build a Shopping Basket Under a Hard Budget — Here's What Broke</title>
      <dc:creator>Ohad Farkash</dc:creator>
      <pubDate>Fri, 28 Aug 2026 15:06:02 +0000</pubDate>
      <link>https://dev.to/ohadfarkash/i-asked-ai-to-build-a-shopping-basket-under-a-hard-budget-heres-what-broke-1e2f</link>
      <guid>https://dev.to/ohadfarkash/i-asked-ai-to-build-a-shopping-basket-under-a-hard-budget-heres-what-broke-1e2f</guid>
      <description>&lt;p&gt;I run &lt;a href="https://onefindme.com/en/" rel="noopener noreferrer"&gt;OneFindMe&lt;/a&gt;, an AI product-search front end for a&lt;br&gt;
large marketplace. The last thing I shipped was a feature that sounds trivial and&lt;br&gt;
isn't: type a need and a &lt;em&gt;hard&lt;/em&gt; budget — "useful things for a big dog, 80 total" —&lt;br&gt;
and get back a real basket of real products that never goes a cent over.&lt;/p&gt;

&lt;p&gt;The word doing the work is &lt;em&gt;real&lt;/em&gt;. The model is not allowed to invent a single&lt;br&gt;
product, price, rating or shipping figure. Everything with a number attached comes&lt;br&gt;
from the marketplace API. The model only gets to do the one thing it's actually&lt;br&gt;
good at: understand what a human meant. Drawing that line is where all the&lt;br&gt;
interesting failures lived.&lt;/p&gt;

&lt;p&gt;Here's what broke.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the AI is actually allowed to do
&lt;/h2&gt;

&lt;p&gt;When someone types "useful things for a big dog, 80," the model does not return&lt;br&gt;
products. It returns a &lt;em&gt;plan&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"domain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"dog"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pet"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"כלב"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"roles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"he"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Chew toy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"kw"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dog chew toy"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"he"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Leash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="nl"&gt;"kw"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"large dog leash"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"he"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Brush"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="nl"&gt;"kw"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pet hair brush"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"he"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bowl"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nl"&gt;"kw"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dog food bowl"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. A set of complementary &lt;em&gt;roles&lt;/em&gt; that together serve the need, each with&lt;br&gt;
a search keyword. No prices, no products, no ratings — the model never sees a&lt;br&gt;
catalogue. Every role keyword then goes to the real search endpoint, in parallel,&lt;br&gt;
and comes back with real listings: price, image, rating, order count, affiliate&lt;br&gt;
link. The AI understood the intent; the marketplace supplied the facts.&lt;/p&gt;

&lt;p&gt;This split is the whole design. The moment you let a language model emit a price&lt;br&gt;
or a product name, you've built a very confident fiction generator. Keep it on the&lt;br&gt;
intent side of the wall and it's genuinely useful.&lt;/p&gt;
&lt;h2&gt;
  
  
  The data that simply does not exist: shipping
&lt;/h2&gt;

&lt;p&gt;The feature has a switch: &lt;em&gt;does the budget include shipping?&lt;/em&gt; Honoring it turned&lt;br&gt;
out to be impossible in the obvious way, because &lt;strong&gt;the affiliate API does not&lt;br&gt;
return a shipping cost.&lt;/strong&gt; It returns an item price and a delivery time in &lt;em&gt;days&lt;/em&gt; —&lt;br&gt;
never a freight figure. There is no endpoint that gives you "this item ships to&lt;br&gt;
that country for X."&lt;/p&gt;

&lt;p&gt;I could have had the model estimate shipping. That's exactly the invention I'd&lt;br&gt;
banned. So the honest version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Include shipping"&lt;/strong&gt; → filter the search to free-shipping items only. Now
shipping is a &lt;em&gt;verified&lt;/em&gt; zero, not a guess, and the budget math stays true.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Products only"&lt;/strong&gt; → ignore shipping entirely and say so.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Currency was a smaller version of the same lesson: the marketplace converts prices&lt;br&gt;
server-side when you pass a target currency, so there is no live FX call to&lt;br&gt;
reconcile per item — you just work in one currency the whole way through.&lt;br&gt;
Coupons exist in the payload but change without notice, so they're surfaced as a&lt;br&gt;
caveat, never subtracted from the total. If a number can't be trusted, it doesn't&lt;br&gt;
get to move the budget.&lt;/p&gt;
&lt;h2&gt;
  
  
  Choosing items without going over: yes, it's Knapsack
&lt;/h2&gt;

&lt;p&gt;Once each role has a pool of real candidates, picking a combination that maximizes&lt;br&gt;
value without exceeding the budget is the &lt;a href="https://en.wikipedia.org/wiki/Knapsack_problem" rel="noopener noreferrer"&gt;0/1 Knapsack&lt;br&gt;
problem&lt;/a&gt; wearing a shopping hat.&lt;/p&gt;

&lt;p&gt;I didn't reach for a full dynamic-programming solution, for three reasons: the&lt;br&gt;
item count is small (a handful of roles, ~20 candidates each), the whole thing&lt;br&gt;
runs inside a request budget of a few seconds, and there's a constraint textbook&lt;br&gt;
knapsack doesn't have — &lt;em&gt;diversity&lt;/em&gt;. Two chew toys is not a good dog basket even&lt;br&gt;
if the numbers are optimal.&lt;/p&gt;

&lt;p&gt;So it's a greedy build with a fill pass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1. base: cheapest viable item per role, so every role is covered&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rolesByCheapest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;spend&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pick&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;basket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pick&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;spend&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;pick&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 2. fill: add new-role items (variety first), then upgrade to pricier/better&lt;/span&gt;
&lt;span class="c1"&gt;//    picks, until only a few units of budget remain&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;budget&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;spend&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;SLACK&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;basket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;add&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bestAffordableNewRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spend&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// prefer an unused role&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;up&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bestUpgradeThatUsesBudget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spend&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// else spend up on a better item&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;add&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;up&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The base pass guarantees coverage. The fill pass is what makes the basket actually&lt;br&gt;
&lt;em&gt;feel&lt;/em&gt; like the budget you asked for — which brings me to the bug that embarrassed&lt;br&gt;
me most.&lt;/p&gt;
&lt;h2&gt;
  
  
  The basket that spent 36 of a 200 budget
&lt;/h2&gt;

&lt;p&gt;Early on, a "cosmetics basket, 200" came back at 36. Technically valid — every&lt;br&gt;
item real, under budget, nothing invented. Practically useless. A customer asking&lt;br&gt;
for a 200 basket and getting 36 worth of stuff feels short-changed, not thrifty.&lt;/p&gt;

&lt;p&gt;The cause was the value function. "Quality first" scored items by rating and&lt;br&gt;
sales, which has no opinion about &lt;em&gt;using the budget&lt;/em&gt;. It happily picked one cheap,&lt;br&gt;
well-rated item per role and stopped. The fix was two-part: scale the number of&lt;br&gt;
roles with the budget (a 200 cosmetics basket wants 6–9 item types, not 3), and&lt;br&gt;
add the fill loop above, which explicitly targets a near-full budget. Cosmetics at&lt;br&gt;
200 now lands at ~199. Never over — that constraint is absolute — but close enough&lt;br&gt;
that the number you typed is the number you get.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug that made it look like a scam
&lt;/h2&gt;

&lt;p&gt;The one that actually scared me: a &lt;strong&gt;nail-polish basket returned a women's coat&lt;/strong&gt;&lt;br&gt;
for 80. Nothing about a coat belongs in a nail order.&lt;/p&gt;

&lt;p&gt;Root cause was a relevance shortcut. Each role carried a "must contain" keyword to&lt;br&gt;
filter noise, and the role &lt;em&gt;top coat&lt;/em&gt; had contributed &lt;code&gt;coat&lt;/code&gt;. A listing titled&lt;br&gt;
"Women Suede Coat" matched &lt;code&gt;coat&lt;/code&gt; and sailed through. A generic word from one role&lt;br&gt;
had opened the door to a completely different category.&lt;/p&gt;

&lt;p&gt;The fix was to stop filtering per role and filter per &lt;em&gt;basket&lt;/em&gt;. The planner now&lt;br&gt;
returns a &lt;code&gt;domain&lt;/code&gt; — a few need-specific stems, in every language the title might&lt;br&gt;
be in — and &lt;strong&gt;every&lt;/strong&gt; item, whatever its role, must contain one of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nail&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;polish&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;manicure&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ציפורנ&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// for "nail polish"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;relevant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stem&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;titleLocal&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A coat contains none of &lt;code&gt;nail / polish / manicure&lt;/code&gt;, so it's gone — regardless of&lt;br&gt;
which role's keyword it happened to match. Precision beat recall here on purpose:&lt;br&gt;
in a basket, one off-topic item reads as "this thing is broken," and I'd rather&lt;br&gt;
drop a borderline product than ship the coat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Speed beat correctness. Again.
&lt;/h2&gt;

&lt;p&gt;The first working version took 10–16 seconds cold: one planning call to the model,&lt;br&gt;
then N marketplace searches. Users don't wait 15 seconds for a basket; they leave.&lt;/p&gt;

&lt;p&gt;Two things fixed it. The searches were already cached, so the second time anyone&lt;br&gt;
builds a similar basket the role searches are warm. And the &lt;em&gt;plan&lt;/em&gt; — the roles for&lt;br&gt;
a given need and budget band — is cacheable too, and priority-independent, so I&lt;br&gt;
cache it and skip the model entirely on a repeat. A brand-new query is still&lt;br&gt;
~10s (N cold marketplace round-trips are the floor), but a repeat is &lt;strong&gt;0.4s&lt;/strong&gt;. As&lt;br&gt;
the cache warms across users, more baskets land in the fast path. Same lesson I&lt;br&gt;
keep relearning: a correct answer that arrives too late is a wrong answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when a price changes after you build the basket
&lt;/h2&gt;

&lt;p&gt;It will. Prices and stock on a live marketplace move by the hour. The basket you&lt;br&gt;
show is a snapshot, and pretending otherwise is the same sin as inventing a&lt;br&gt;
shipping figure. So the total is computed server-side from the freshest search at&lt;br&gt;
build time, the items carry the marketplace's own "prices may change" caveat, and&lt;br&gt;
the buy links go straight to the live listing where the real, current price is&lt;br&gt;
authoritative. The basket is a &lt;em&gt;starting point that respects your budget&lt;/em&gt;, not a&lt;br&gt;
locked quote — and it says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern underneath all of it
&lt;/h2&gt;

&lt;p&gt;Every one of these fixes is the same move: &lt;strong&gt;let the model interpret, never let it&lt;br&gt;
assert.&lt;/strong&gt; It's brilliant at turning "stuff for a big dog, 80" into search terms and&lt;br&gt;
domain anchors. It's a liability the instant it emits a price. Keep the language&lt;br&gt;
work and the truth work on opposite sides of a hard wall, and the failures stop&lt;br&gt;
being "the AI hallucinated" and start being ordinary, fixable engineering — a&lt;br&gt;
missing field, a too-greedy heuristic, a generic keyword that matched the wrong&lt;br&gt;
thing.&lt;/p&gt;

&lt;p&gt;The budget basket that came out of it is live on OneFindMe if you want to see the&lt;br&gt;
shape of it. But the interesting part was never the demo. It was the wall.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Localizing a marketplace search for 12 markets: the assumptions that cost me</title>
      <dc:creator>Ohad Farkash</dc:creator>
      <pubDate>Tue, 25 Aug 2026 10:19:24 +0000</pubDate>
      <link>https://dev.to/ohadfarkash/localizing-a-marketplace-search-for-12-markets-the-assumptions-that-cost-me-3a9j</link>
      <guid>https://dev.to/ohadfarkash/localizing-a-marketplace-search-for-12-markets-the-assumptions-that-cost-me-3a9j</guid>
      <description>&lt;p&gt;I run &lt;a href="https://onefindme.com/en/" rel="noopener noreferrer"&gt;OneFindMe&lt;/a&gt;, an AI product-search front end for&lt;br&gt;
AliExpress in 12 languages. Translating the UI was the easy part. The hard part&lt;br&gt;
was everything I &lt;em&gt;assumed&lt;/em&gt; about what each market wants — and kept getting wrong&lt;br&gt;
until I measured instead.&lt;/p&gt;

&lt;p&gt;This is three of those assumptions, what the data actually said, and the code&lt;br&gt;
that changed. If you're localizing anything commerce-shaped past the string&lt;br&gt;
table, you'll recognize the pattern: the bug is never the translation, it's the&lt;br&gt;
belief underneath it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Assumption 1: "same homepage, translated, is localized"
&lt;/h2&gt;

&lt;p&gt;The first version showed every market the same "today's deals" row — the same&lt;br&gt;
eight products, just translated. A shopper in Saudi Arabia and a shopper in&lt;br&gt;
Brazil saw identical items in different words.&lt;/p&gt;

&lt;p&gt;That isn't localization, it's translation wearing a localization costume. The&lt;br&gt;
Gulf shopper doesn't want the same products as the Brazilian one; they want&lt;br&gt;
things that sell &lt;em&gt;there&lt;/em&gt; — an abaya, an oud diffuser, an Arabic coffee pot.&lt;br&gt;
Showing them a translated version of someone else's shortlist is worse than&lt;br&gt;
showing nothing, because it signals the site doesn't actually know their market.&lt;/p&gt;

&lt;p&gt;The fix was per-market seed lists, chosen by &lt;strong&gt;audience before geography&lt;/strong&gt;. A&lt;br&gt;
single dispatch, checked in this order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;dealsTerms&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ar&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;ARAB_COUNTRIES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;DEALS_GULF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;IL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;DEALS_IL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EU_COUNTRIES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;DEALS_EU&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;DEALS_INTL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Language is checked &lt;strong&gt;before&lt;/strong&gt; country on purpose: an Arabic speaker who happens&lt;br&gt;
to be browsing from Germany should still get the Gulf row, not the EU one. The&lt;br&gt;
person's language is a stronger signal of what they're shopping for than the IP&lt;br&gt;
they happen to be behind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; a translated shortlist is not a localized shortlist. Localize the&lt;br&gt;
&lt;em&gt;selection&lt;/em&gt;, not just the labels — and let the audience signal win over the geo&lt;br&gt;
signal when they disagree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Assumption 2: I knew what a market considered acceptable
&lt;/h2&gt;

&lt;p&gt;Here's the one I'm least proud of, and the most useful.&lt;/p&gt;

&lt;p&gt;A women's-clothing search on the Arabic surface returned some items I looked at&lt;br&gt;
and thought: &lt;em&gt;I should filter these out for a conservative market.&lt;/em&gt; I started&lt;br&gt;
writing a modesty filter — block anything that looked immodest on the Arabic&lt;br&gt;
side. It felt responsible.&lt;/p&gt;

&lt;p&gt;Then I did the thing I should have done first: I opened the actual AliExpress&lt;br&gt;
Saudi storefront, as a Saudi user, and ran the same query. &lt;strong&gt;AliExpress itself&lt;br&gt;
puts "sexy"-labelled and form-fitting clothing on the first page of a plain&lt;br&gt;
dress search there.&lt;/strong&gt; Immodest clothing is the market norm, not an edge case.&lt;/p&gt;

&lt;p&gt;My filter would have made my engine &lt;em&gt;stricter than the store the shopper was&lt;br&gt;
walking into anyway&lt;/em&gt; — blocking a sports bra from someone who searched for&lt;br&gt;
women's clothing, while the destination site shows it on page one. That's not&lt;br&gt;
protecting anyone; it's just lost results.&lt;/p&gt;

&lt;p&gt;So I threw the modesty filter away. What I kept was much narrower: a filter for&lt;br&gt;
things that were a &lt;strong&gt;relevance&lt;/strong&gt; failure in any market — a fetish costume&lt;br&gt;
surfacing on a search for "dress" is wrong for a shopper in Riyadh &lt;em&gt;and&lt;/em&gt; one in&lt;br&gt;
Tel Aviv. That list is short and explicit, and deliberately excludes words with&lt;br&gt;
innocent uses (a "nightclub dress" is an ordinary party dress; "Lolita" is a&lt;br&gt;
real fully-covering fashion style; "sexy" is 42% of AliExpress's own first&lt;br&gt;
page).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; don't encode your assumption about a market into a filter. Go&lt;br&gt;
observe the market — the real benchmark for what to show is the store the user&lt;br&gt;
is heading to. I almost shipped a paternalistic filter built on a guess; four&lt;br&gt;
minutes of looking inverted the whole decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Assumption 3: more traffic in a language means demand in that language
&lt;/h2&gt;

&lt;p&gt;I'd built full localized pages for three Gulf markets, confident all three had&lt;br&gt;
Arabic shopping demand. Then I pulled the search-console data per country, and&lt;br&gt;
two of the three had &lt;strong&gt;almost no Arabic queries at all&lt;/strong&gt; — the "traffic" was&lt;br&gt;
my own users mis-geolocated through carrier routing and VPNs, searching in other&lt;br&gt;
languages entirely.&lt;/p&gt;

&lt;p&gt;I'd spent weeks building for demand that a five-minute export would have shown&lt;br&gt;
me wasn't there. Worse: content I'd written as filler in one language quietly&lt;br&gt;
out-performed the pages I'd carefully localized, because that's where the real&lt;br&gt;
demand was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; "traffic from country X" and "demand in language X" are different&lt;br&gt;
measurements, and you can't tell them apart without looking at the actual&lt;br&gt;
queries. Export the per-market data &lt;em&gt;before&lt;/em&gt; you build the per-market page, not&lt;br&gt;
after.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern under all three
&lt;/h2&gt;

&lt;p&gt;Every one of these was the same shape: a reasonable-sounding assumption about a&lt;br&gt;
market I wasn't in, encoded into code, that a small measurement would have&lt;br&gt;
corrected before I wrote a line. Translating strings is a solved problem.&lt;br&gt;
Localizing &lt;em&gt;judgment&lt;/em&gt; — what to show, what to filter, what to build — is where&lt;br&gt;
the real work is, and it's all measurement, not intuition.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Localize the selection, not just the strings.&lt;/strong&gt; A translated shortlist isn't
local.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the audience signal beat the geo signal&lt;/strong&gt; when they conflict.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observe the market before you filter it.&lt;/strong&gt; The benchmark is the store the
user is going to, not your idea of that market.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-market demand data before per-market pages.&lt;/strong&gt; Traffic ≠ demand.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building commerce in markets you don't personally live in, the&lt;br&gt;
uncomfortable truth is that your instincts about those markets are a liability&lt;br&gt;
until they're checked. Mine were wrong three times in a row. Curious whether&lt;br&gt;
anyone's found a faster way to catch these than shipping and measuring — the&lt;br&gt;
comments are open.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://onefindme.com/en/" rel="noopener noreferrer"&gt;OneFindMe&lt;/a&gt; — AI product search for&lt;br&gt;
AliExpress by text or image, in 12 languages. It's free; it runs on affiliate&lt;br&gt;
commission at no extra cost to the buyer.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>i18n</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Building a 12-language AI product search on the edge: what actually broke</title>
      <dc:creator>Ohad Farkash</dc:creator>
      <pubDate>Tue, 25 Aug 2026 10:10:26 +0000</pubDate>
      <link>https://dev.to/ohadfarkash/building-a-12-language-ai-product-search-on-the-edge-what-actually-broke-45kb</link>
      <guid>https://dev.to/ohadfarkash/building-a-12-language-ai-product-search-on-the-edge-what-actually-broke-45kb</guid>
      <description>&lt;p&gt;I run &lt;a href="https://onefindme.com/en/" rel="noopener noreferrer"&gt;OneFindMe&lt;/a&gt;, an AI product-search front end for&lt;br&gt;
AliExpress. You describe what you want in plain language — in any of 12 languages&lt;br&gt;
— or upload a photo, and it returns the product, similar items, and cheaper&lt;br&gt;
alternatives. It runs entirely on a Cloudflare Worker with an LLM doing the&lt;br&gt;
language work.&lt;/p&gt;

&lt;p&gt;This isn't a launch post. It's the three problems that were genuinely hard, the&lt;br&gt;
wrong first solutions I shipped, and what actually fixed them. If you're putting&lt;br&gt;
an LLM in front of a marketplace search API, you'll hit all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack, briefly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Workers&lt;/strong&gt; for the whole API — search, translation, image
understanding, caching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Haiku&lt;/strong&gt; for query translation and image identification. I tried Sonnet too, but for this task — short product names and image labels — Haiku was actually the better fit, and it's far cheaper when every search is an LLM call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workers KV&lt;/strong&gt; as the cache layer.&lt;/li&gt;
&lt;li&gt;A static multilingual front end on Cloudflare Pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The core loop is: take a natural-language query in any language → turn it into a&lt;br&gt;
clean marketplace search term → hit the affiliate search API → rank and filter →&lt;br&gt;
return. The interesting failures are all in the "turn it into a clean search&lt;br&gt;
term" step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: the model translated &lt;em&gt;too well&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;The first version asked the model to "translate this shopping query to English."&lt;br&gt;
It did — beautifully, fluently, and uselessly.&lt;/p&gt;

&lt;p&gt;A user searching for a &lt;code&gt;שמלת ערב&lt;/code&gt; (evening dress) got back&lt;br&gt;
&lt;code&gt;an elegant formal gown suitable for evening occasions&lt;/code&gt;. Grammatically perfect.&lt;br&gt;
It also returned almost nothing from the marketplace, because &lt;strong&gt;nobody titles a&lt;br&gt;
product listing in fluent prose.&lt;/strong&gt; Marketplace sellers write&lt;br&gt;
&lt;code&gt;Women Elegant Evening Party Dress Sexy Backless&lt;/code&gt; — keyword soup, not sentences.&lt;/p&gt;

&lt;p&gt;The fix was to stop asking for translation and start asking for &lt;strong&gt;the 2-3 word&lt;br&gt;
noun phrase a seller would put in a title.&lt;/strong&gt; The prompt changed from "translate"&lt;br&gt;
to "return the short product name an AliExpress seller would use." Fluency was&lt;br&gt;
the enemy; the model's instinct to produce natural language was exactly wrong for&lt;br&gt;
a keyword search index.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; when an LLM feeds a keyword system, you don't want its best language.&lt;br&gt;
You want the language of the target index. Prompt for that explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: the model invented category IDs, and they outranked reality
&lt;/h2&gt;

&lt;p&gt;To narrow results, I let the model suggest an AliExpress category ID alongside&lt;br&gt;
the keywords. Category-constrained search returns cleaner results — when the ID&lt;br&gt;
is real.&lt;/p&gt;

&lt;p&gt;The model would confidently return category IDs that &lt;strong&gt;did not exist.&lt;/strong&gt; Not&lt;br&gt;
often, but often enough. And a nonexistent category ID doesn't error — it returns&lt;br&gt;
an empty or garbage result set, which then &lt;em&gt;replaced&lt;/em&gt; the perfectly good&lt;br&gt;
keyword-only results the same query would have produced. The hallucinated&lt;br&gt;
constraint silently beat the honest fallback.&lt;/p&gt;

&lt;p&gt;Two things fixed it. First, a hard allow-list: category IDs the model proposes&lt;br&gt;
are checked against a map of known-good IDs and dropped if unrecognised. Second,&lt;br&gt;
and more important, the keyword search always runs; the category is an&lt;br&gt;
&lt;em&gt;optional&lt;/em&gt; refinement layered on top, never a replacement. If the category path&lt;br&gt;
returns nothing, the keyword results are still there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; never let a model's optional enrichment silently override your&lt;br&gt;
deterministic baseline. Layer it, gate it, and make the baseline win by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: cold search was 6-8 seconds, and that was the whole business
&lt;/h2&gt;

&lt;p&gt;An uncached search does real work: an LLM call to build the query, the&lt;br&gt;
marketplace API round trip, ranking, filtering. Cold, that's 6-8 seconds. Users&lt;br&gt;
don't wait 6-8 seconds. The single biggest driver of bounce wasn't relevance —&lt;br&gt;
it was latency on the first search.&lt;/p&gt;

&lt;p&gt;The cache helps enormously: every search result is cached in KV for up to 30&lt;br&gt;
days, so a warm search returns in ~200 ms. But you can't cache a query nobody has&lt;br&gt;
run yet, and the &lt;em&gt;first&lt;/em&gt; person to search a term pays the full cost.&lt;/p&gt;

&lt;p&gt;Two moves cut the &lt;em&gt;perceived&lt;/em&gt; wait to near zero without making the search&lt;br&gt;
actually faster:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Instant bestsellers.&lt;/strong&gt; The moment a search starts, the UI shows a row of
known-good bestseller results for the category while the real search runs
behind it. The screen is never empty; the real results swap in when ready.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-warming on unknown terms.&lt;/strong&gt; When a new keyword is translated for the
first time, the translation is saved &lt;em&gt;and&lt;/em&gt; the search is pre-cached, so the
&lt;em&gt;next&lt;/em&gt; person to search that term — and there's almost always a next person —
gets the 200 ms warm path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Neither makes the cold path faster. Both make it invisible. That distinction —&lt;br&gt;
optimising perceived latency instead of actual latency — moved the metric that&lt;br&gt;
mattered more than any relevance tuning did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; on a search product, the empty-state-while-loading is a feature, not&lt;br&gt;
a gap. Show &lt;em&gt;something&lt;/em&gt; instantly and backfill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one I'd warn you about hardest
&lt;/h2&gt;

&lt;p&gt;A subtle one, because it looks like success: &lt;strong&gt;don't trust the marketplace's own&lt;br&gt;
"is this product available" signal in isolation.&lt;/strong&gt; The affiliate API would report&lt;br&gt;
live, purchasable products as gone. Filtering on it alone silently emptied result&lt;br&gt;
pages that should have been full. Availability needs corroboration, not a single&lt;br&gt;
boolean — the same lesson as the hallucinated category, in a different costume:&lt;br&gt;
one unreliable signal shouldn't be allowed to zero out a good result set.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself at the start
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prompt for the target system's language, not the user's.&lt;/strong&gt; A keyword index
wants keywords, not prose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A model's optional output must never override your deterministic path.&lt;/strong&gt;
Gate it against known-good values; layer it; let the baseline win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimise perceived latency first.&lt;/strong&gt; Instant partial results beat a faster
spinner every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One signal should never zero out a result set.&lt;/strong&gt; Corroborate before you
filter to empty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The engine runs in 12 languages now, and every one of those bugs showed up&lt;br&gt;
identically in each. If you're building anything that puts an LLM between a human&lt;br&gt;
sentence and a structured search index, you'll meet all four. Happy to compare&lt;br&gt;
notes in the comments — especially if you've found a better answer to the&lt;br&gt;
cold-search problem than "show bestsellers and pray."&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://onefindme.com/en/" rel="noopener noreferrer"&gt;OneFindMe&lt;/a&gt; — AI product search for&lt;br&gt;
AliExpress by text or image, in 12 languages. It's free; it runs on affiliate&lt;br&gt;
commission at no extra cost to the buyer.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>cloudflare</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
