<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Web Tool Build Notes]]></title><description><![CDATA[Web Tool Build Notes]]></description><link>https://webtool-notes.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 21 Oct 2026 08:47:39 GMT</lastBuildDate><atom:link href="https://webtool-notes.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Ten Open-Data Portals, Ten Ways to List Their APIs: Notes From a Weekly Crawl]]></title><description><![CDATA[As of October 18, 2026 We are a small team that builds free web tools. One of them is an index of the APIs that national government open-data portals…]]></description><link>https://webtool-notes.hashnode.dev/ten-open-data-portals-ten-ways-to-list-their-apis-notes-from-a-weekly-crawl</link><guid isPermaLink="true">https://webtool-notes.hashnode.dev/ten-open-data-portals-ten-ways-to-list-their-apis-notes-from-a-weekly-crawl</guid><category><![CDATA[api]]></category><category><![CDATA[#OpenData]]></category><category><![CDATA[Python]]></category><category><![CDATA[data-engineering]]></category><dc:creator><![CDATA[Sihwan Oh]]></dc:creator><pubDate>Sun, 18 Oct 2026 01:42:45 GMT</pubDate><content:encoded><![CDATA[<p>As of October 18, 2026</p>
<p>We are a small team that builds free web tools. One of them is an index of the APIs that national government open-data portals publish, one country per edition: Korea, Japan, the United States, France, Germany, the United Kingdom, Taiwan, Canada, Australia and Singapore. Every week a Python script re-reads each portal and keeps only the entries that can actually be called as an API or a web service.</p>
<p>The interesting part was not the index. It was finding out that ten portals answer the question "which of your entries are APIs?" in ten different ways. These are the notes from building that crawler, with the endpoints we use.</p>
<h2>Three families of portal</h2>
<p>Roughly, the portals fall into three groups:</p>
<ul>
<li><strong>CKAN instances</strong> (Germany, the UK, Canada, Australia). You get a generic dataset search and have to work out for yourself which datasets are services.</li>
<li><strong>Portals with a dedicated API list</strong> (France, Korea, Singapore, and the US in its own way). Somebody already decided what counts as an API.</li>
<li><strong>No list endpoint at all</strong> (Japan), or <strong>one big export file</strong> (Taiwan).</li>
</ul>
<p>Each family needs a different strategy, and each has one trap that cost us a run.</p>
<h2>CKAN: ask the facets first</h2>
<p>On a CKAN portal there is no "is this an API?" field. What you have is the format string on each resource, typed by whoever published it. So the first call is not a search, it is a facet count over every format the portal has ever seen:</p>
<pre><code class="language-text">GET https://data.gov.au/data/api/3/action/package_search?rows=0&amp;facet.field=["res_format"]&amp;facet.limit=-1
</code></pre>
<p>On October 18, 2026 that returned 416 distinct format strings on data.gov.au. Twenty-three of them look like a service, and six of those mention WMS alone:</p>
<pre><code class="language-text">WMS
OGC WMS
wms &amp; wfs
ArcGIS REST WMS WFS
ArcGIS REST  WMS  WFS
arcgis rest wms wfs
</code></pre>
<p>So you cannot filter on <code>res_format:WMS</code> and call it done. We match the facet values against one regular expression and then build a single filter query from the exact strings that matched:</p>
<pre><code class="language-python">SVC = re.compile(
    r"(?&lt;![a-z])(api|rest|wms|wfs|wcs|wmts|sparql|oaf|odata|_srvc)(?![a-z])"
    r"|arcgis.*(server|service)", re.I)

fac = get(f"{base}/package_search?rows=0&amp;facet.field=%5B%22res_format%22%5D&amp;facet.limit=-1")
fmts = sorted(f for f in fac["result"]["facets"]["res_format"] if SVC.search(f.rsplit("/", 1)[-1]))
fq = "res_format:(" + " OR ".join('"%s"' % f.replace('"', '\\"') for f in fmts) + ")"
</code></pre>
<p>Two details matter. The lookarounds stop <code>api</code> matching inside words like <code>rapid</code>, and the <code>rsplit("/")</code> handles portals where the format is a URI (some EU-style vocabularies) rather than a word. The <code>arcgis.*(server|service)</code> branch keeps ArcGIS REST services while leaving out ArcGIS Online story maps and web apps, which are pages, not APIs.</p>
<p>Then page through with a stable sort:</p>
<pre><code class="language-python">r = get(f"{base}/package_search?" + urlencode(
    {"fq": fq, "rows": 1000, "start": start, "sort": "id asc"}))["result"]
</code></pre>
<p><code>sort=id asc</code> is there on purpose. Paging with the default relevance sort is not guaranteed to be stable between requests, and on a catalog that is being harvested while you read it, rows can move between pages.</p>
<p>The base URLs differ more than you would expect for the same software: <code>ckan.govdata.de/api/3/action</code> for Germany, <code>ckan.publishing.service.gov.uk/api/action</code> for the UK (we switched to it because data.gov.uk/api returned an error page on October 11, 2026), <code>open.canada.ca/data/api/action</code> for Canada and <code>data.gov.au/data/api/3/action</code> for Australia. Canada also has an API collection, so we run a second filter, <code>collection:api</code>, and merge the two result sets by dataset id.</p>
<h2>Portals that already list their APIs</h2>
<p><strong>France.</strong> data.gouv.fr has a separate API catalog, <code>/api/1/dataservices/</code>, distinct from its datasets. Everything in it is an API, so the only filtering is dropping entries with <code>archived_at</code> or <code>deleted_at</code> set. It pages with a <code>next_page</code> link, and each record carries <code>base_api_url</code> and a machine-readable documentation URL when the publisher filled one in. On October 18 the endpoint reported 1,266 entries.</p>
<p><strong>Korea.</strong> data.go.kr publishes its own list of open APIs as an API (dataset 15077093, served from <code>api.odcloud.kr</code>), which needs a free service key. The list is already API-only. Licence and the type of the providing body are not in that list, so we read them from each API's catalog JSON and from the portal search page.</p>
<p><strong>Singapore.</strong> The v2 public endpoint <code>api-production.data.gov.sg/v2/public/api/datasets?page=N</code> returns every dataset with a <code>format</code> field. We keep <code>format == "API"</code> and <code>status == "active"</code>. The portal's CSV and GeoJSON datasets are queryable through the same API, but they are file datasets, so they stay out of the index.</p>
<p><strong>United States.</strong> Data.gov's catalog search has no format filter. We run four searches (<code>API</code>, <code>"OGC WMS"</code>, <code>WMS</code>, <code>WFS</code>) and keep only records whose distributions actually carry one of those types, de-duplicating by identifier. This is where paging bit us: paging the relevance-sorted results with the <code>after</code> cursor returned overlapping rows. On October 11 one run collected 25,842 rows of which only 20,560 were unique, and it stopped about halfway. Sorting by <code>last_harvested_date</code> removed the overlap. The search also returns HTTP 429 if you go too fast, so there is a two-second pause between pages.</p>
<h2>No list endpoint: read the pages</h2>
<p><strong>Japan.</strong> The Digital Agency's e-Gov API Catalog has no endpoint that returns the list. We read the list page 50 at a time until the count printed on the page (「件のAPIが見つかりました」) is reached, then open each detail page and pull fields by their labels: providing organisation, API format, response format, public URL. It is 38 entries, so a one-second delay per request is cheap. The e-Gov data portal had zero resources registered in API format on October 11, so it is not used.</p>
<p><strong>Taiwan.</strong> data.gov.tw offers the whole catalog as a single JSON export. We keep datasets whose provision type is 「API服務」 or whose file formats include <code>WEBSERVICES</code>, <code>API</code>, <code>WMS</code> or <code>WFS</code>. Of 52,513 datasets in the October 17 export, 1,045 qualified.</p>
<h2>What a weekly run looks like</h2>
<table>
<thead>
<tr>
<th>Portal</th>
<th>Method</th>
<th>Total in source</th>
<th>Kept as API (read 2026-10-17)</th>
</tr>
</thead>
<tbody><tr>
<td>GovData (DE)</td>
<td>CKAN facets + fq</td>
<td>22,146</td>
<td>22,146</td>
</tr>
<tr>
<td>data.gov.au</td>
<td>CKAN facets + fq</td>
<td>18,489</td>
<td>18,489</td>
</tr>
<tr>
<td>Data.gov (US)</td>
<td>4 searches + type check</td>
<td>13,772</td>
<td>13,772</td>
</tr>
<tr>
<td>data.go.kr</td>
<td>list API</td>
<td>12,015</td>
<td>12,057</td>
</tr>
<tr>
<td>data.gov.uk</td>
<td>CKAN facets + fq</td>
<td>5,148</td>
<td>5,148</td>
</tr>
<tr>
<td>open.canada.ca</td>
<td>CKAN facets + API collection</td>
<td>3,427</td>
<td>3,427</td>
</tr>
<tr>
<td>data.gouv.fr</td>
<td>dataservices endpoint</td>
<td>1,266</td>
<td>1,266</td>
</tr>
<tr>
<td>data.gov.tw</td>
<td>JSON export, filtered</td>
<td>52,513</td>
<td>1,045</td>
</tr>
<tr>
<td>e-Gov (JP)</td>
<td>HTML list + detail pages</td>
<td>38</td>
<td>38</td>
</tr>
<tr>
<td>data.gov.sg</td>
<td>v2 datasets, format = API</td>
<td>4,663</td>
<td>22</td>
</tr>
</tbody></table>
<p>For CKAN and the US, "total in source" is already the filtered search result, which is why the two columns match. For Korea, the first number is the count on the portal's search page and the second is what the list API returned, which was slightly higher.</p>
<h2>One guard worth copying</h2>
<p>Portals fail in quiet ways: an error page with HTTP 200, a search that returns half its usual rows, an expired key. The crawler compares each country's new row count with its last good run and refuses to overwrite the data if it shrank by more than half, exiting with code 1. The weekly job calls each country separately, so one broken portal keeps last week's edition while the other nine update.</p>
<p>If you only need one portal, the CKAN facet trick and the French dataservices endpoint are the two pieces most worth reusing. If you want to search all ten without writing the crawler, the index is at <a href="https://govapiatlas.com/">govapiatlas.com</a>. It does not host the APIs; applying for keys and calling them happens on each portal.</p>
]]></content:encoded></item><item><title><![CDATA[How Big Is k? Measuring Have I Been Pwned's Range Buckets From Node]]></title><description><![CDATA[As of October 9, 2026 "k-anonymity" gets attached to the Pwned Passwords range API in almost every write-up, usually without a value for k. If you are…]]></description><link>https://webtool-notes.hashnode.dev/how-big-is-k-measuring-have-i-been-pwned-s-range-buckets-from-node</link><guid isPermaLink="true">https://webtool-notes.hashnode.dev/how-big-is-k-measuring-have-i-been-pwned-s-range-buckets-from-node</guid><category><![CDATA[Node.js]]></category><category><![CDATA[Security]]></category><category><![CDATA[api]]></category><category><![CDATA[passwords]]></category><category><![CDATA[privacy]]></category><dc:creator><![CDATA[Sihwan Oh]]></dc:creator><pubDate>Sun, 11 Oct 2026 01:42:15 GMT</pubDate><content:encoded><![CDATA[<p>As of October 9, 2026</p>
<p>"k-anonymity" gets attached to the Pwned Passwords range API in almost every write-up, usually without a value for k. If you are adding a breached-password check to a signup flow, that number tells you how much the API operator, or anyone watching the network, could infer from the one thing you send.</p>
<p>This post measures it: one short Node script, real responses, and the design decisions that follow.</p>
<h2>What the documentation promises</h2>
<p>The <a href="https://haveibeenpwned.com/API/v3#PwnedPasswords">API documentation</a> describes the contract:</p>
<ul>
<li>You send the first five hex characters of a SHA-1 (or NTLM) hash to <code>https://api.pwnedpasswords.com/range/{prefix}</code>.</li>
<li>You get back every known hash suffix that shares that prefix, one per line, as <code>SUFFIX:COUNT</code>.</li>
<li>There are 16⁵ = 1,048,576 possible prefixes, and every one returns HTTP 200.</li>
<li>No API key, and no rate limit.</li>
<li>An optional <code>Add-Padding: true</code> request header adds decoy rows with a count of 0.</li>
<li><code>?mode=ntlm</code> switches the response to NTLM suffixes.</li>
</ul>
<p>The same page says a typical range returns approximately 800 suffixes and that padded responses contain between 800 and 1,000 rows. Those two figures are the ones worth checking, because the data set keeps growing.</p>
<h2>The script</h2>
<p>No dependencies. It needs Node 18 or later for the built-in <code>fetch</code> and <code>AbortSignal.timeout</code>.</p>
<pre><code class="language-javascript">// survey.mjs — run with: node survey.mjs
import { createHash } from 'node:crypto';

const sha1 = text =&gt; createHash('sha1').update(text, 'utf8').digest('hex').toUpperCase();

async function bucket(prefix, { padding = false, mode } = {}) {
  const url = `https://api.pwnedpasswords.com/range/${prefix}${mode ? `?mode=${mode}` : ''}`;
  const headers = { 'User-Agent': 'range-bucket-survey' };
  if (padding) headers['Add-Padding'] = 'true';
  const res = await fetch(url, { headers, signal: AbortSignal.timeout(5000) });
  if (!res.ok) throw new Error(`HTTP ${res.status} for ${prefix}`);
  const body = await res.text();
  const rows = body.trim().split(/\r?\n/).map(line =&gt; {
    const [suffix, count] = line.split(':');
    return { suffix, count: Number(count) };
  });
  return { rows, bytes: Buffer.byteLength(body) };
}

// 1. One lookup, end to end, with a password everyone knows is burned
const hash = sha1('password');
const prefix = hash.slice(0, 5);
const suffix = hash.slice(5);
const plain = await bucket(prefix);
const hit = plain.rows.find(row =&gt; row.suffix === suffix);
console.log(`prefix ${prefix}, suffix length ${suffix.length}`);
console.log(`plain : ${plain.rows.length} rows, ${plain.bytes} bytes, count for "password" = ${hit ? hit.count : 0}`);

// 2. The same bucket, padded, twice
for (const run of [1, 2]) {
  const padded = await bucket(prefix, { padding: true });
  const zeros = padded.rows.filter(row =&gt; row.count === 0).length;
  console.log(`padded run ${run}: ${padded.rows.length} rows (${zeros} with count 0), ${padded.bytes} bytes`);
}

// 3. How big are other buckets?
const sizes = [];
for (const p of ['00000', '7C4A8', 'ABCDE', 'FFFFF']) {
  const { rows } = await bucket(p);
  sizes.push(rows.length);
  console.log(`bucket ${p}: ${rows.length} rows`);
}
const mean = Math.round(sizes.reduce((a, b) =&gt; a + b, 0) / sizes.length);
console.log(`mean of those four: ${mean} rows; x 1,048,576 buckets = about ${(mean * 1048576 / 1e9).toFixed(1)} billion hashes`);

// 4. NTLM mode
const ntlm = await bucket(prefix, { mode: 'ntlm' });
console.log(`ntlm suffix length: ${ntlm.rows[0].suffix.length}`);
</code></pre>
<h2>What came back</h2>
<p>Output from a run on October 9, 2026. Your row counts will differ a little, since the data set changes and padding is random.</p>
<pre><code class="language-text">prefix 5BAA6, suffix length 35
plain : 1978 rows, 77639 bytes, count for "password" = 52372427
padded run 1: 2020 rows (42 with count 0), 79277 bytes
padded run 2: 2174 rows (196 with count 0), 85283 bytes
bucket 00000: 2509 rows
bucket 7C4A8: 1921 rows
bucket ABCDE: 2046 rows
bucket FFFFF: 2047 rows
mean of those four: 2131 rows; x 1,048,576 buckets = about 2.2 billion hashes
ntlm suffix length: 27
</code></pre>
<p>Four observations.</p>
<p><strong>k is around 2,000 now.</strong> Every bucket we sampled held between 1,921 and 2,509 suffixes, well above the "approximately 800" in the documentation's description. Five buckets is a tiny sample, so treat 2,000 as an order of magnitude and the 2.2 billion extrapolation as a rough estimate. The direction is plain enough: the anonymity set has grown with the corpus.</p>
<p><strong>The response is not small.</strong> About 78 KB of text once decoded. On a signup endpoint that is the dominant cost of the check, larger than the hash and comparison by far. It is worth a timeout, which the script sets at five seconds.</p>
<p><strong>Padding added tens to hundreds of rows.</strong> The two padded calls to the same bucket returned 2,020 and 2,174 rows, with 42 and 196 zero-count decoys. Because the unpadded bucket is already past 1,000 rows, the padded size here is "real rows plus a random extra", not the 800 to 1,000 range the documentation describes. The property that matters still holds: two requests for the same prefix produced different lengths, so response size alone is a weaker signal of which bucket was fetched.</p>
<p><strong>NTLM suffixes are 27 characters.</strong> An NTLM hash is 32 hex characters, so five go in the URL and 27 come back. That matches the documentation and is useful if you are auditing Windows credential stores, where NTLM is what you already hold.</p>
<h2>What k does and does not hide</h2>
<p>With five hex characters you reveal 20 bits of a 160-bit hash. The operator learns that the password you are checking is one of roughly 2,000 known-breached candidates in that bucket, or something that is in none of them. For a password that is not in the corpus, which is the outcome you hope for, the prefix narrows nothing useful: the remaining 140 bits are unknown.</p>
<p>What the scheme does not hide is the request itself. The operator and the network path see an IP address, a time and a prefix. If your server makes the call on behalf of users, that IP is yours, not theirs, which is an argument for doing the lookup server-side in a signup flow. If the browser makes the call, the user's IP is exposed to the API and the password never reaches your server for this purpose. Both are defensible. Pick one deliberately.</p>
<h2>Decisions for a signup or password-change check</h2>
<p>NIST's current digital identity guideline says verifiers "SHALL compare" a prospective password against a blocklist that includes <a href="https://pages.nist.gov/800-63-4/sp800-63b.html">passwords obtained from previous breach corpuses</a>. A range lookup is one way to meet that. A few choices decide whether it behaves well in production.</p>
<p><strong>Fail open or fail closed?</strong> If the API times out, you can reject the signup or let it through. Rejecting turns a third party's outage into yours. Accepting quietly means a breached password occasionally slips in. A reasonable middle: accept, record that the check was skipped, and re-run it at next login. Whatever you choose, never report "not found" when you mean "could not check".</p>
<p><strong>Compare the whole suffix, exactly.</strong> The response uses uppercase hex. Normalize your own hash to uppercase once and do an exact string match on all 35 characters. With padding on, a decoy row can only be mistaken for a hit if you ignore the count, so treat a matched row with count 0 as no match.</p>
<p><strong>Do not transform the password first.</strong> Hash the exact bytes the user typed, in UTF-8. Trimming or lowercasing checks a different password from the one being set.</p>
<p><strong>Cache by prefix if you must, briefly.</strong> A bucket is the same for every user who lands in it, so a short-lived cache keyed by prefix is safe from a privacy standpoint and saves the 78 KB download. Keep it short, because new breaches are loaded into the corpus. The documentation notes there are no licensing or attribution requirements on this API, so nothing in the terms forbids holding a response for a few minutes.</p>
<p><strong>Send a User-Agent.</strong> The documentation's general rules require one and describe a 403 without it. When we tried the range endpoint with the header removed it still answered 200, but that is behaviour, not contract. Set a descriptive value and you never have to find out which endpoints enforce it.</p>
<h2>What this check is not</h2>
<p>A clean range lookup says the password is not in one corpus. It says nothing about whether the password is short, guessable, or reused by this user elsewhere. Keep the length rule and any strength estimate as separate checks with separate messages.</p>
<p>We are a small team that builds free web tools, and one of the things we build is <a href="https://pwcheck.fyi/en/">a browser-based checker</a> that follows this split: the breach lookup hashes locally and sends five characters of the hash, while a strength score is computed on the device and shown beside it. If you are designing the user-facing side of this, it is a quick way to see the two results presented as two things.</p>
<p>See the lookup and the strength estimate side by side: <a href="https://pwcheck.fyi/en/">pwcheck.fyi</a></p>
]]></content:encoded></item><item><title><![CDATA[Why the Same 45 Characters Make Three Different QR Codes: Encoding Modes as a Byte Budget]]></title><description><![CDATA[As of October 9, 2026 Here is a puzzle any QR generator can produce: you change one word in a link and the code comes out visibly denser. Nothing is wrong…]]></description><link>https://webtool-notes.hashnode.dev/why-the-same-45-characters-make-three-different-qr-codes-encoding-modes-as-a-byte-budget</link><guid isPermaLink="true">https://webtool-notes.hashnode.dev/why-the-same-45-characters-make-three-different-qr-codes-encoding-modes-as-a-byte-budget</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[unicode]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Sihwan Oh]]></dc:creator><pubDate>Sat, 10 Oct 2026 01:47:59 GMT</pubDate><content:encoded><![CDATA[<p>As of October 9, 2026</p>
<p>Here is a puzzle any QR generator can produce: you change one word in a link and the code comes out visibly denser. Nothing is wrong with the generator. The payload crossed a capacity boundary and the encoder moved up a version, which makes the symbol four modules wider and taller.</p>
<p>Where those boundaries fall depends on something most generator UIs never show: the encoding mode. This post makes it visible with a small script, then turns the result into rules you can apply before a design is locked.</p>
<h2>Four modes, four prices</h2>
<p>A QR symbol stores a bit stream. The standard defines several ways to turn characters into bits, and they are not equally compact. DENSO WAVE, which created the format, lists the maximum capacity of the largest symbol as <a href="https://www.qrcode.com/en/about/standards.html">7,089 numeric, 4,296 alphanumeric, 2,953 byte and 1,817 kanji characters</a>. Same symbol, same number of bits; the difference is the price per character.</p>
<table>
<thead>
<tr>
<th>Mode</th>
<th>What it accepts</th>
<th>Cost</th>
</tr>
</thead>
<tbody><tr>
<td>Numeric</td>
<td><code>0</code>–<code>9</code></td>
<td>10 bits per 3 digits</td>
</tr>
<tr>
<td>Alphanumeric</td>
<td><code>0</code>–<code>9</code>, <code>A</code>–<code>Z</code>, space and <code>$ % * + - . / :</code></td>
<td>11 bits per 2 characters</td>
</tr>
<tr>
<td>Byte</td>
<td>any byte</td>
<td>8 bits per byte</td>
</tr>
<tr>
<td>Kanji</td>
<td>characters from Shift JIS</td>
<td>13 bits per character</td>
</tr>
</tbody></table>
<p>Two details in that table do most of the damage in practice. The alphanumeric set has no lowercase letters. And byte mode charges per byte, not per character, so the cost of non-Latin text depends on how you serialized it.</p>
<h2>Measure it</h2>
<p>The script uses the <code>qrcode</code> package's <code>create()</code> function, which returns the symbol's version and the segments the encoder chose. Install it with <code>npm i qrcode@1.5.4</code>.</p>
<pre><code class="language-javascript">// modes.js — npm i qrcode@1.5.4 &amp;&amp; node modes.js
const QRCode = require('qrcode');
const toSJIS = require('qrcode/helper/to-sjis');

function describe(label, text, options = {}) {
  const qr = QRCode.create(text, { errorCorrectionLevel: 'M', ...options });
  const modes = qr.segments.map(s =&gt; `${s.mode.id}(${s.getLength()})`).join(' + ');
  const bytes = Buffer.byteLength(text, 'utf8');
  const size = qr.modules.size;
  console.log(`${label.padEnd(16)} ${String(text.length).padStart(3)} chars ${String(bytes).padStart(3)} bytes  v${qr.version} ${size}x${size}  ${modes}`);
}

const url = 'https://example.com/menu/autumn-2026?table=12';
describe('mixed-case URL', url);
describe('upper-case URL', url.toUpperCase());
describe('digits only', '8801234567890'.repeat(3));
describe('korean', '가을 메뉴판은 여기에서 확인하세요');
describe('japanese', '秋のメニューはこちらからご覧ください');
describe('japanese (kanji)', '秋のメニューはこちらからご覧ください', { toSJISFunc: toSJIS });

console.log('');
for (const level of ['L', 'M', 'Q', 'H']) {
  describe(`URL at level ${level}`, url, { errorCorrectionLevel: level });
}

console.log('');
// How many repeats of one character fit in version 3 at level M?
function fits(unit) {
  let n = 0;
  while (QRCode.create(unit.repeat(n + 1), { errorCorrectionLevel: 'M' }).version &lt;= 3) n++;
  return n;
}
console.log(`v3-M holds: ${fits('7')} digits | ${fits('A')} upper-case | ${fits('a')} lower-case | ${fits('가')} hangul`);
</code></pre>
<p>Output:</p>
<pre><code class="language-text">mixed-case URL    45 chars  45 bytes  v4 33x33  Byte(45)
upper-case URL    45 chars  45 bytes  v3 29x29  Alphanumeric(36) + Byte(9)
digits only       39 chars  39 bytes  v2 25x25  Numeric(39)
korean            18 chars  48 bytes  v4 33x33  Byte(48)
japanese          18 chars  54 bytes  v4 33x33  Byte(54)
japanese (kanji)  18 chars  54 bytes  v3 29x29  Kanji(18)

URL at level L    45 chars  45 bytes  v3 29x29  Byte(45)
URL at level M    45 chars  45 bytes  v4 33x33  Byte(45)
URL at level Q    45 chars  45 bytes  v4 33x33  Byte(45)
URL at level H    45 chars  45 bytes  v6 41x41  Byte(45)

v3-M holds: 101 digits | 61 upper-case | 42 lower-case | 14 hangul
</code></pre>
<h2>Reading the output</h2>
<p><strong>One lowercase letter forces byte mode.</strong> The mixed-case URL is a single 45-byte segment at version 4. Uppercased, the same address splits into an alphanumeric run of 36 characters and a 9-byte tail, and fits version 3. The tail is <code>?TABLE=12</code>: neither <code>?</code> nor <code>=</code> is in the alphanumeric set, so the encoder falls back to bytes from the first character it cannot represent.</p>
<p><strong>Digits are almost free.</strong> Thirty-nine digits fit in version 2. A numeric ticket ID or a GTIN is the cheapest thing you can encode.</p>
<p><strong>CJK text costs three bytes a character in UTF-8.</strong> Eighteen Korean characters, three of them spaces, serialize to 48 bytes. Eighteen Japanese characters come to 54. Both need version 4, the same as a 45-character Latin URL. The last line of the output puts a number on it: version 3 at level M holds 42 lowercase letters or 14 Hangul syllables.</p>
<p><strong>Kanji mode claws that back for Japanese.</strong> With a Shift JIS conversion function supplied, the same Japanese sentence encodes as 18 kanji-mode characters and drops to version 3. There is no equivalent compact mode for Hangul, so Korean text always pays the UTF-8 price.</p>
<p><strong>Error correction level is the other multiplier.</strong> The 45-character URL is version 3 at L, version 4 at M and Q, and version 6 at H. Going from M to H added eight modules to each side. If a design adds a centre logo and the tool raises the level to H to compensate, the symbol grows even though the payload did not change.</p>
<h2>Can you just uppercase the URL?</h2>
<p>Partly. RFC 3986 says <a href="https://www.rfc-editor.org/rfc/rfc3986#section-6.2.2.1">the scheme and host are case-insensitive</a>, and that the other components are assumed to be case-sensitive unless the scheme says otherwise. So <code>HTTPS://EXAMPLE.COM</code> is equivalent to its lowercase form, and whether <code>/MENU/AUTUMN-2026</code> reaches the same page as <code>/menu/autumn-2026</code> depends on your server.</p>
<p>If you control the server, a route that is deliberately case-insensitive and avoids <code>?</code>, <code>=</code> and <code>&amp;</code> can stay entirely in alphanumeric mode. <code>HTTPS://EXAMPLE.COM/M/12</code> is 24 characters at 5.5 bits each. That is a legitimate optimization for codes printed very small. For everything else, the simpler win is a shorter path.</p>
<h2>The single-byte trap</h2>
<p>Byte mode stores bytes. Some encoder libraries, including the rendering library our own generator uses, treat each character of a JavaScript string as a single byte, which corrupts anything outside Latin-1 unless you prepare the string first. The usual fix is to hand the library a string in which each character already stands for one UTF-8 byte:</p>
<pre><code class="language-javascript">const utf8 = s =&gt; unescape(encodeURIComponent(s));

const s = '가A';
console.log([...utf8(s)].map(c =&gt; c.charCodeAt(0).toString(16)).join(' '), '| length', s.length, '→', utf8(s).length);
// ea b0 80 41 | length 2 → 4
</code></pre>
<p><code>encodeURIComponent</code> percent-encodes the UTF-8 bytes, and <code>unescape</code> turns each <code>%XX</code> back into a single character with that code. The result is a "binary string" four characters long whose character codes are the four UTF-8 bytes. <code>unescape</code> is a legacy function; <code>new TextEncoder().encode(s)</code> gives you the same bytes as a <code>Uint8Array</code> if your library accepts one. Either way, check a non-Latin payload by scanning it, and budget by <code>Buffer.byteLength(text, 'utf8')</code> or <code>TextEncoder</code>, never by <code>text.length</code>.</p>
<h2>A budget you can apply before design starts</h2>
<p>Using version 3 at level M (29 × 29 modules) as the target for a small print:</p>
<table>
<thead>
<tr>
<th>Payload type</th>
<th>Fits in v3-M</th>
<th>Typical use</th>
</tr>
</thead>
<tbody><tr>
<td>Digits</td>
<td>101</td>
<td>ticket numbers, product codes</td>
</tr>
<tr>
<td>Uppercase alphanumeric</td>
<td>61</td>
<td>short codes, case-insensitive URLs</td>
</tr>
<tr>
<td>Lowercase or mixed text, ASCII</td>
<td>42 bytes</td>
<td>ordinary URLs</td>
</tr>
<tr>
<td>Hangul, kana or Han in UTF-8</td>
<td>14 characters</td>
<td>a few words</td>
</tr>
</tbody></table>
<p>Rules that follow:</p>
<ol>
<li><p>Budget in bytes for anything that is not purely digits or the alphanumeric set.</p>
</li>
<li><p>Keep query strings out of printed codes where you can. <code>?</code> and <code>=</code> end an alphanumeric run, and tracking parameters are usually the longest part of the payload.</p>
</li>
<li><p>Put non-Latin text behind a short URL instead of into the symbol, unless it is Japanese and your encoder and target scanners handle kanji mode.</p>
</li>
<li><p>Decide the error correction level before measuring, and measure again if a logo is added later.</p>
</li>
<li><p>Log the version in your build or export step. A jump from one version to the next is the earliest warning that a payload change will alter the artwork.</p>
</li>
</ol>
<h2>Where this shows up in a generator</h2>
<p>We are a small team that builds free web tools, and one of the things we build is <a href="https://makeqr.fyi/en/">a QR code generator</a> that runs entirely in the browser. It applies the UTF-8 conversion shown above before handing text to its rendering library, so Korean and Japanese payloads scan correctly, and it switches error correction to H when a logo is added. Both behaviours are the reason a code can change density when the visible text barely changed, which is where this post started.</p>
<p>The script is the more useful takeaway. Point it at your own payloads and you will know the version, and so the module count, before anyone opens a design tool.</p>
<p>Try payloads of different kinds and watch the symbol change: <a href="https://makeqr.fyi/en/">makeqr.fyi</a></p>
]]></content:encoded></item></channel></rss>