<?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: Artur Daschevici</title>
    <description>The latest articles on DEV Community by Artur Daschevici (@adaschevici).</description>
    <link>https://dev.to/adaschevici</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%2F361785%2Fc49561cf-b2fc-457f-b21e-0ecae2f9aeef.png</url>
      <title>DEV Community: Artur Daschevici</title>
      <link>https://dev.to/adaschevici</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/adaschevici"/>
    <language>en</language>
    <item>
      <title>What the rewrite deleted</title>
      <dc:creator>Artur Daschevici</dc:creator>
      <pubDate>Wed, 09 Sep 2026 10:34:00 +0000</pubDate>
      <link>https://dev.to/adaschevici/what-the-rewrite-deleted-5ae2</link>
      <guid>https://dev.to/adaschevici/what-the-rewrite-deleted-5ae2</guid>
      <description>&lt;p&gt;&lt;em&gt;Part three of a series on &lt;a href="https://github.com/gitbadger-clan/chops-search" rel="noopener noreferrer"&gt;chops-search&lt;/a&gt;, a hybrid search engine for static sites. This one is less about search than about code review, and it stands alone. Part two is &lt;a href="https://unicow.dev/blog/chops-search-fusion/" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://unicow.dev/blog/chops-search-fusion/" rel="noopener noreferrer"&gt;Part two&lt;/a&gt; ended with a persistence batch: three calibrated scoring values (min_gap, rrf_alpha, an optional min_cos override) becoming config keys, riding into index.bin next to the field weights under one version bump, and getting read out by the engine at construction. The point was to close an honesty gap, since CI had been certifying a configuration that existed only as CLI flags while every visitor's browser ran the defaults. After the batch: the config file states the scoring, the build bakes it, the browser runs it, and a bare eval measures it. One configuration in four places, none of them shell history.&lt;/p&gt;

&lt;p&gt;The batch came with a boundary rule worth restating because everything below leans on it: persist what you calibrated, default what you did not. Only the three knobs that earned calibrated values got config keys. rrf_k stays a compiled constant because its sweep was flat at every value. strong_cos stays off because no fixture case has ever needed the hatch. Every knob now has one of three provenances, corpus-calibrated in the artifact, compiled constant, or per-run flag override, and the scoring: line that eval prints marks flag overrides with an asterisk, because a sweep transcript that cannot distinguish "the index shipped 0.08" from "a flag injected 0.08" is the exact debt the batch exists to close, in a new denomination.&lt;/p&gt;

&lt;p&gt;The batch landed. Tests green, artifacts building, search working. This post is about the review that happened next, because the review found seven problems, and the pattern across all seven is the thing worth writing down: a rewrite deletes what it does not understand, and deletions are invisible in the green path. Tests pass, everything works, and the file is quietly more fragile than the one it replaced. The review question that paid was not "is the new code right?" It was "what did the old code do that the new code no longer does?"&lt;/p&gt;

&lt;h2&gt;
  
  
  First, the one genuinely interesting design decision
&lt;/h2&gt;

&lt;p&gt;Before the review findings, the wire-format decision the batch forced, because two of the tests it produced show up later.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;min_cos&lt;/code&gt; is an override, not a default, and "absent" and "0.0" are different sentences. Absent means "derive the floor from embedding dimensionality at construction," which is right for almost every corpus and tracks a dims change automatically. An explicit 0.0 means "floor off," per the house convention that floors disable at zero. Conflate them and one of two legitimate engines becomes unspellable.&lt;/p&gt;

&lt;p&gt;So the wire keeps them distinct with a fixed-width presence flag: one byte, then a four-byte f32 value field, written as zero when absent and ignored on read. Five bytes where four would have blurred the distinction. Fixed width rather than conditional, so the layout stays trivially seekable and the absent case has exactly one byte representation, which matters here because the artifact filenames are content hashes, and a nondeterministic byte forces every visitor on the planet to re-download the index.&lt;/p&gt;

&lt;p&gt;The distinction had to be pinned at every layer that could lose it, because every layer had a habit that would merge them: a defaulted TOML key, a bare float field in the struct, an &lt;code&gt;unwrap_or&lt;/code&gt; in the constructor. Three tests: the wire test asserts &lt;code&gt;None&lt;/code&gt; and &lt;code&gt;Some(0.0)&lt;/code&gt; produce different bytes and both round-trip, the config test asserts &lt;code&gt;min_cos = 0.0&lt;/code&gt; parses to an armed override while an absent key stays &lt;code&gt;None&lt;/code&gt;, and the engine test asserts all three states produce the three intended engines. Keeping one distinction alive across three serialization boundaries takes a presence flag, three tests, and the willingness to spend the extra byte on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The review: seven absences
&lt;/h2&gt;

&lt;p&gt;The batch landed as large rewrites of &lt;code&gt;format.rs&lt;/code&gt; and &lt;code&gt;config.rs&lt;/code&gt; rather than surgical diffs, which is what made the review question necessary. Here is what the old code did that the new code no longer did, ordered by how much it would have cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dropped bounds checks.&lt;/strong&gt; The old &lt;code&gt;Index::read&lt;/code&gt; rejected chunk entries and posting doc ids pointing past the document count. The rewrite dropped the checks, and &lt;code&gt;Engine::new&lt;/code&gt; indexes per-doc arrays with those values unguarded. In native code that is a caught panic with a stack trace. In wasm it is an aborted module. A truncated or corrupted artifact went from "loud FormatError with a rebuild instruction" to "the search box dies silently on every page of the site." The same rewrite dropped the NaN rejection on field weights, whose failure mode is quieter still: NaN does not crash anything, it just NaNs every score it touches, and search returns garbage with complete confidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The thematically perfect one.&lt;/strong&gt; The config parser's unknown-key rejection disappeared &lt;em&gt;in the same batch that made config keys load-bearing for scoring&lt;/em&gt;. The old code had the check, and a comment defending it: "a misspelled chunk_size that silently does nothing is worse than a failed build." Without it, a typo'd &lt;code&gt;min_gp = 0.08&lt;/code&gt; parses cleanly and ships the gate disarmed. Which is the honesty gap this entire batch was built to close, reintroduced through a spelling error. The check went back with the three new keys in its list, and the regression test's fixture is literally the string &lt;code&gt;min_gp&lt;/code&gt;. A typo should cost a build, not a calibration, and the stricter the meaning of a config file, the stricter its parser has to be about words it does not know.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fofq22ahd0pjvqkqee32z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fofq22ahd0pjvqkqee32z.png" alt="Terminal: Error: in chops-search.toml. Caused by: unknown key min_gp, followed by the list of known keys, which includes min_gap." width="799" height="130"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The parser's answer to the typo. &lt;code&gt;min_gap&lt;/code&gt; sits right there in the known-keys list, which is the point.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two defaults changed silently inside the refactor.&lt;/strong&gt; The compiled &lt;code&gt;dims&lt;/code&gt; default moved from &lt;code&gt;None&lt;/code&gt; (native size) to &lt;code&gt;Some(128)&lt;/code&gt;, making "native" unspellable and contradicting the field's own doc comment three lines above it. The model path changed too. Both reverted, with the 128 claim moved to where it belongs: the two deployed sites' config files, where the size-versus-recall decision is visible in a diff. Here is the uncomfortable part: 128 was probably even the right value, and that is exactly what made it dangerous, because nothing looked wrong. Defaults changed inside a refactor are claims smuggled past review. A behavior change carries its own commit, its own doc update, and its own measurement, or it carries a future confusion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The unreachable error message.&lt;/strong&gt; The batch renamed the artifact magic strings in the same commit that bumped the format version. That combination made the carefully worded version-mismatch error unreachable by its only intended audience: a developer with a stale pre-batch &lt;code&gt;out/&lt;/code&gt; directory now fails the magic check before the version check ever runs, and gets "not an index.bin," which is actively confusing because it &lt;em&gt;is&lt;/em&gt; one, just old. The fix recognizes the legacy magics specifically and emits the rebuild message from the magic branch. Writing a good error is half the work. The other half is checking who can actually arrive at it, because an error message is only as good as the path that reaches it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fixlsxpkh3sfk5xppgcqz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fixlsxpkh3sfk5xppgcqz.png" alt="Terminal: Error: inconsistent artifact: model.meta.bin was built by an older chops-search version; run chops-search build to regenerate." width="795" height="83"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A pre-batch &lt;code&gt;out/&lt;/code&gt; meeting the new binary. The meta file trips the check first, and the message names the fix instead of denying the file.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the housekeeping tier&lt;/strong&gt;, listed because absences come in all sizes: value rails lost (&lt;code&gt;dims = 0&lt;/code&gt; and &lt;code&gt;chunk_chars = 10&lt;/code&gt; were accepted without complaint), a doc comment on &lt;code&gt;with_overrides&lt;/code&gt; stating the exact opposite of the function below it, and &lt;code&gt;bytes.rs&lt;/code&gt; orphaned because the format rewrite grew its own private byte-cursor, leaving two implementations for the next format change to drift between. Reunified rather than deleted.&lt;/p&gt;

&lt;p&gt;Every one of the seven was an absence: a bounds check, a key list, a rail, a reachable error path. The green path cannot show you a deleted guard, because guards only exist for inputs the happy tests never send. The question that finds them is mechanical and worth ritualizing: diff the old file against the new one and account for every rejection the old code could produce.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rails, mirrored
&lt;/h2&gt;

&lt;p&gt;The hardening that came out of the review has one organizing idea: &lt;strong&gt;the artifact must not be able to carry a value the config parser would have refused.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The same range checks, cosine-space quantities finite in &lt;code&gt;0..=1&lt;/code&gt;, alpha finite in &lt;code&gt;0..=100&lt;/code&gt;, now run in three places with one shape: on the TOML keys, on the build flags (through a shared validator, so a flag cannot bake what a key could not), and in the binary reader. The reader check is the one that earns its keep in the dark. A NaN &lt;code&gt;min_gap&lt;/code&gt; read from a corrupted artifact would never gate anything, because &lt;code&gt;gap &amp;lt; NaN&lt;/code&gt; is false, and the engine would run ungated forever while every test stayed green, every build succeeded, and every visitor got the junk results the gate exists to suppress. Nobody would ever know.&lt;/p&gt;

&lt;p&gt;The payoff of checking at the door is what it licenses inside. &lt;code&gt;Engine::new&lt;/code&gt; gets to index unguarded and the gate gets to compare unchecked precisely because &lt;code&gt;read&lt;/code&gt; refused everything else. Validation is not paranoia layered on trust. Validation is the license for trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scaffold teaches without deciding
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;chops-search init&lt;/code&gt; generates a starter config, and the batch posed a small design question with a sharp edge: the generated file is where a user learns what keys exist, and the three most consequential keys were invisible in it. But scaffolding &lt;code&gt;min_gap = 0.08&lt;/code&gt; would ship one corpus's calibration as if it were a universal default, and even an innocent-looking &lt;code&gt;min_cos = 0.0&lt;/code&gt; would arm the override on a site that should be deriving its floor.&lt;/p&gt;

&lt;p&gt;The resolution: commented-out examples, with the calibration loop spelled out beside them. Sweep with &lt;code&gt;eval&lt;/code&gt;, verify the mechanism with &lt;code&gt;explain&lt;/code&gt;, pin the winner in config, rebuild. The scaffold teaches the keys exist and how to earn values for them, without asserting values it has no basis for.&lt;/p&gt;

&lt;p&gt;Then the part that makes it durable: the template went under test. One test parses the generated file and asserts every scoring knob comes out inert, every live key is known to the parser, and no value is smuggled inside a comment block. A second strips the comment markers off the example lines and parses &lt;em&gt;that&lt;/em&gt;, so the exact text a user will uncomment is guaranteed to be valid keys with in-range values, and a future key rename cannot leave the scaffold documenting a key the parser rejects. The template is now under test the same way the engine is, because it is an interface the same way the engine is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Help text is a claim about defaults
&lt;/h2&gt;

&lt;p&gt;A side effect of index-carried defaults that was easy to miss: four flag doc comments became lies the moment the batch landed.&lt;/p&gt;

&lt;p&gt;"--min-gap, default 0 (disabled)" was true when the engine could only ever get 0. On a calibrated corpus, the default is now whatever &lt;code&gt;index.bin&lt;/code&gt; shipped, and a user reading the help and omitting the flag would believe they were running ungated while the artifact gated at 0.08. Same for &lt;code&gt;--rrf-alpha&lt;/code&gt;. Same for &lt;code&gt;--min-cos&lt;/code&gt;, whose stated 0.20 was doubly wrong, being the 256-dim constant on top of the stale assumption. Same for build's &lt;code&gt;--dims&lt;/code&gt; after the native-default restoration. Each now says "default: whatever index.bin was built with" or names the derivation instead of asserting a number.&lt;/p&gt;

&lt;p&gt;The post-sweep "lock it in" hint got the same treatment. It used to prescribe carrying the winning flags around in your shell. Now it prescribes a config key and a rebuild, because locking in is precisely the thing the batch redefined. Help text is documentation that executes in the user's head at the worst possible moment, mid-debugging, and it deserves the same review as the code it describes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Definition of done
&lt;/h2&gt;

&lt;p&gt;The captures in this post are from &lt;code&gt;534e1dd&lt;/code&gt;, one batch after the one described here, by which point &lt;code&gt;chunk_penalty&lt;/code&gt; had earned a key of its own through the same sweep-and-pin loop and the &lt;code&gt;min_cos&lt;/code&gt; override had been dropped in favor of the derived floor. &lt;code&gt;min_gap&lt;/code&gt; and &lt;code&gt;rrf_alpha&lt;/code&gt; are as this batch shipped them.&lt;/p&gt;

&lt;p&gt;The acceptance sequence for the batch, in order: workspace tests green. Two consecutive builds byte-identical on the real corpus, because the unit test asserts byte-stability on a toy index and only the real corpus proves it at scale. A stale pre-batch &lt;code&gt;out/&lt;/code&gt; producing the rebuild message rather than a parse failure. And then the line the whole arc was for: bare &lt;code&gt;chops-search eval&lt;/code&gt;, no flags, scoring header reading the calibrated values with no asterisks on them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3j5u3d3dwkl6rwhgepik.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3j5u3d3dwkl6rwhgepik.png" alt="Terminal: the eval scoring line reading min_cos 0.28, chunk_penalty 0.120, min_gap 0.08, rrf_alpha 1.00 with no asterisks, then corpus 19 docs, cases 37, and an OVERALL row of 86% recall at one and 92% at three." width="800" height="146"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Bare &lt;code&gt;eval&lt;/code&gt;, no flags. Every value came out of &lt;code&gt;index.bin&lt;/code&gt;; the 0.28 floor is derived from &lt;code&gt;dims = 128&lt;/code&gt;, not set anywhere. 37 cases, 86% recall@1.&lt;/em&gt; &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqwl44w5f6iem965m7dgr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqwl44w5f6iem965m7dgr.png" alt="Terminal: four negative rows. espresso grinder burrs, toddler bedtime routine, and orchid repotting schedule PASS with no results in keyword-only mode. bicycle chain lubricant FAILs, returning /how-to/reindex-in-ci/ in hybrid mode." width="800" height="118"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The four negative controls from the same run. Three return nothing: rows are warm in eval, so &lt;code&gt;[kw]&lt;/code&gt; means the gate suppressed the semantic list and keyword matching had no evidence to offer. The fourth gets through in hybrid mode: no keyword evidence, but a top-median gap of 0.161 against &lt;code&gt;min_gap 0.08&lt;/code&gt;, so the gate never fired. That is a real failure at the shipped configuration, and it belongs in the picture.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The last verification happens after deploy, and it is the one this whole series has been pointed at: type "toddler bedtime routine" into the live search box and watch it return nothing.&lt;/p&gt;

&lt;p&gt;One open item surfaced while planning the next round of measurement, and it makes a fitting place to stop. No fixture case exercises the &lt;code&gt;strong_cos&lt;/code&gt; hatch shape: keyword-empty, flat field, genuinely relevant top document. Which means a sweep of that knob is blind by construction until the case is written. The gap was found not by a failure but by asking what a sweep would even see, and the principle generalizes past search engines: a knob you cannot measure is a knob you cannot calibrate, and the time to notice is before the sweep, not after it returns a column of identical numbers you cannot interpret.&lt;/p&gt;

&lt;p&gt;The engine, the config parser with its &lt;code&gt;min_gp&lt;/code&gt; fixture, the scaffold tests, and the format reader are all in the repo: &lt;a href="https://github.com/gitbadger-clan/chops-search" rel="noopener noreferrer"&gt;github.com/gitbadger-clan/chops-search&lt;/a&gt;. The series starts with &lt;a href="https://unicow.dev/blog/static-website-semantic-search/" rel="noopener noreferrer"&gt;a launch post about range-fetching an embedding model&lt;/a&gt; and runs through &lt;a href="https://unicow.dev/blog/chops-search-eval/" rel="noopener noreferrer"&gt;the eval that scored 65%&lt;/a&gt; and &lt;a href="https://unicow.dev/blog/chops-search-fusion/" rel="noopener noreferrer"&gt;the knobs running out&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>codereview</category>
      <category>testing</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>The knobs run out</title>
      <dc:creator>Artur Daschevici</dc:creator>
      <pubDate>Wed, 02 Sep 2026 09:42:00 +0000</pubDate>
      <link>https://dev.to/adaschevici/the-knobs-run-out-np0</link>
      <guid>https://dev.to/adaschevici/the-knobs-run-out-np0</guid>
      <description>&lt;p&gt;&lt;a href="https://unicow.dev/blog/chops-search-eval/" rel="noopener noreferrer"&gt;Part one&lt;/a&gt; ended at a plateau: 34 of 46 cases passing at the honest configuration, recall@3 thirteen points above recall@1, and a hypothesis. The right page was almost always retrieved and simply was not first, so the problem had to live wherever "first" gets decided, and in a hybrid engine that place is rank fusion. RRF's conventional &lt;code&gt;k = 60&lt;/code&gt; is calibrated for TREC-scale runs fusing thousand-deep lists. On a 19-document corpus the reciprocal-rank curve is nearly flat (rank 1 versus rank 2 is 1/61 versus 1/62, a gap of 0.0003), so fusion degenerates toward "best average rank wins" and a decisive first place in one engine cannot survive mediocrity in the other. Nobody calibrates k. It looked like the constant nobody had checked.&lt;/p&gt;

&lt;p&gt;I built a sweep to test it. &lt;code&gt;rrf_k&lt;/code&gt; threaded through &lt;code&gt;ScoreOpts&lt;/code&gt;, seeded from &lt;code&gt;rrf::K&lt;/code&gt; so there is exactly one copy of the constant. &lt;code&gt;--sweep-rrf-k&lt;/code&gt; and &lt;code&gt;--sweep-rrf-alpha&lt;/code&gt; on eval, running all 46 cases per grid point. And a self-validation requirement: the &lt;code&gt;k=60 / alpha=0&lt;/code&gt; cell must reproduce the baseline byte for byte, or the harness itself is suspect. It did.&lt;/p&gt;

&lt;p&gt;Here is what the grid did to the hypothesis:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;recall@1&lt;/th&gt;
&lt;th&gt;alpha 0&lt;/th&gt;
&lt;th&gt;alpha 0.5&lt;/th&gt;
&lt;th&gt;alpha 1&lt;/th&gt;
&lt;th&gt;alpha 2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;k = 2&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;td&gt;72%&lt;/td&gt;
&lt;td&gt;67%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;k = 4&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;td&gt;72%&lt;/td&gt;
&lt;td&gt;72%&lt;/td&gt;
&lt;td&gt;67%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;k = 8&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;td&gt;72%&lt;/td&gt;
&lt;td&gt;72%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;k = 16&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;td&gt;72%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;k = 32&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;td&gt;76%&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;k = 60&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;td&gt;76%&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;(This table is re-measured on the 46-case union at &lt;code&gt;42593bf&lt;/code&gt;: all twenty-four cells reproduced exactly, including the flat first column.)&lt;/p&gt;

&lt;p&gt;Read the first column. 74% at every k from 2 to 60. Fusion resolution is not where recall@1 lives on this corpus, and the hypothesis died in one table. The k that nobody calibrates turns out not to need calibrating here, and the sweep built to confirm the obvious instead falsified it in a single run.&lt;/p&gt;

&lt;p&gt;Two smaller findings came along. The alpha=2 corner at low k drops to 67%: confidence-weighted fusion on a sharp curve actively amplifies keyword mistakes, which is the knob's danger zone documented empirically rather than reasoned about. And the grid's best cell, k=32 / alpha=1 at 76%, is exactly one case above baseline, which on a 46-case set I dismissed as argmax noise. Argmax over a 24-cell grid will always find a cell one case up by chance. That dismissal gets revisited below, and the revisit is the most instructive mistake of the arc.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdi8olhmjunktg43f0ffg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdi8olhmjunktg43f0ffg.png" alt="Heatmap of the 24-cell rrf_k by rrf_alpha grid. The alpha=0 column is flat at 74% for every k. The k=2, alpha=2 corner drops to 67%. The k=60, alpha=1 cell is circled and labeled: dismissed as noise, rehabilitated below." width="800" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A negative result recorded with its grid is a deliverable. This sweep produced no tuning change and was still the most valuable run of its week, because of what it printed next.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the sweep actually bought
&lt;/h2&gt;

&lt;p&gt;The per-kind table redrew the map of where the problem was:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;kind&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;th&gt;recall@1&lt;/th&gt;
&lt;th&gt;recall@3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;exact&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;86%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;navigational&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;negative&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;paraphrase&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;58%&lt;/td&gt;
&lt;td&gt;79%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;(Measured at &lt;code&gt;42593bf&lt;/code&gt;, no flags, summed over the gate and known-failures files: 12/14, 9/9, 2/4, 11/19, total 34/46, matching the baseline exactly. One instrument trap worth recording: the sweep re-runs its best cell verbosely after the grid, so a per-kind table trailing sweep output belongs to the best cell, not the baseline. An earlier draft of this table was the k=32/alpha=1 cell for exactly that reason.)&lt;/p&gt;

&lt;p&gt;Navigational is solved outright and exact is close behind. That is BM25F doing the job it was built for in part one: the "getting started"-class failures are gone, and both kinds sit at 100% by &lt;a href="mailto:recall@3"&gt;recall@3&lt;/a&gt;. The negatives are leaking at exactly 50%, meaning the relevance floor lets junk through on half the off-topic queries. And paraphrase at 58% is the ceiling, with four of its nineteen misses absent from the top 3 entirely, which is retrieval, not ordering, and no fusion or gating knob reaches it.&lt;/p&gt;

&lt;p&gt;The deficit had names, and none of them was fusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The floor versus the gate
&lt;/h2&gt;

&lt;p&gt;The two leaking negatives ("toddler bedtime routine", "bicycle chain lubricant") were semantic cosines sneaking over the derived relevance floor, 0.28 at 128 dims. The obvious fix: raise the floor. &lt;code&gt;min_cos 0.34&lt;/code&gt; cleared both.&lt;/p&gt;

&lt;p&gt;The per-case diff is why that fix was rejected. It read +2/-1, and the casualty was "make the download smaller for visitors", a legitimate paraphrase whose expected winner's best chunk sat at cosine 0.313, inside the same 0.28-to-0.34 band as the noise. The raised floor deleted the right answer's evidence and the fused order collapsed onto the hub page. A second fingerprint confirmed the mechanism: "elasticlunr" flipped from hybrid to keyword-only, a legitimate exact query whose entire semantic corroboration fell below the raised floor.&lt;/p&gt;

&lt;p&gt;On this corpus at this dimensionality, the 0.28-to-0.34 band contains noise and signal simultaneously. No absolute per-document threshold separates two populations occupying the same interval. That sentence is the whole reason the next mechanism exists.&lt;/p&gt;

&lt;p&gt;The corroboration gate, built in part one and shipped disarmed, is the distributional version of the same judgment. Instead of asking "is this cosine high," it asks "does anything stand out from this query's own field," measured as top-minus-median over raw cosines, applied only when the keyword engine contributed nothing. Off-topic queries on a homogeneous corpus produce a flat pack; real paraphrases produce one document above the field.&lt;/p&gt;

&lt;p&gt;The calibration is the part worth stealing. Part one shipped the gate with diagnostics: &lt;code&gt;--explain&lt;/code&gt; prints the gap and top values per query. Toddler's measured gap was 0.059. The sweep missed it at &lt;code&gt;min_gap 0.04&lt;/code&gt; and caught it at 0.06, and the per-case table was identical to baseline at every value from 0.06 through 0.12 except for the one intended flip. Instrument and knob agreeing to the third decimal. When explain and eval move together like that, calibration is a lookup, not a search, and that is the entire argument for shipping a disarmed mechanism with armed diagnostics. The value chosen was 0.08, roughly 35% above the measured noise gap. The &lt;code&gt;strong_cos&lt;/code&gt; escape hatch stayed off: nothing on this corpus needed exempting, and a hatch that ships armed for a problem the corpus does not present is a knob without a constituency.&lt;/p&gt;

&lt;p&gt;The gate is casualty-free by construction on the floor's failure mode: the min_cos casualty had keyword confidence 1.00, and the gate never fires on corroborated queries. The two mechanisms judge disjoint populations, which is why one of them could be armed and the other could not.&lt;/p&gt;

&lt;p&gt;And the second negative survived every gap value, which is the honest counterexample the section needs. "Bicycle chain lubricant" is a structurally different noise mode: one document ringing at a nonsense query, standing out from the pack, which a flatness statistic reads as signal by definition. Any gap value that caught it would also suppress every legitimate semantic-only query whose winner stands out. A statistic designed to detect flat noise cannot detect pointed noise. Disposition: a known-failures entry under a new cause label, spurious resonance, with the blindness documented as inherent rather than pending. Not every failure gets fixed. Some get confessions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alpha's redemption
&lt;/h2&gt;

&lt;p&gt;One explain output changed a verdict I had already published to myself.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;data-chops-open&lt;/code&gt; failure was the case &lt;code&gt;rrf_alpha&lt;/code&gt; was designed against, in its purest observed form. The keyword engine had the compound term right: the correct page at kw#1, score 7.135, on df=2 evidence, about as unambiguous as keyword evidence gets. The semantic engine crowned an honestly adjacent page. And unweighted RRF's arithmetic preferred kw#3 + sem#1 over kw#1 + sem#4, because on a flat curve mediocrity in both engines beats excellence in one. Doing the weighted arithmetic by hand from the explain columns showed alpha=1 flipping it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2/61 + 1/64 = 0.04841   &amp;gt;   2/63 + 1/61 = 0.04814
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That hand calculation forced the revision. The grid's k=60 / alpha=1 cell had shown one case above baseline and been dismissed as argmax noise. It was almost certainly this case. The corrected principle, and both directions of it fired inside one arc: &lt;strong&gt;one flipped case without a mechanism is noise; one flipped case with an explain-verified mechanism is a finding.&lt;/strong&gt; The k=32 cell stays dismissed. The alpha=1 cell gets rehabilitated the moment its case has arithmetic behind it. I applied the noise heuristic one round too early, and the only reason the mistake got caught is that the diagnostic loop kept running after the verdict.&lt;/p&gt;

&lt;p&gt;The combined run, &lt;code&gt;min_gap 0.08&lt;/code&gt; plus &lt;code&gt;alpha 1&lt;/code&gt;, was predicted at two up, zero down. It came back three up, one down. The bonus was "what should i commit and what should i ignore" flipping to PASS on the expected page, and its known-failures comment, written 2026-08-08 in the same commit that re-baselined the gate (&lt;code&gt;2205069&lt;/code&gt;), read: "Verify with &lt;code&gt;chops-search query&lt;/code&gt; when weighted RRF lands: if the keyword list already had manage-the-model first, this comes along for free." It did, and it did. A dated prediction in a fixture comment paying off is the strongest single argument in the file for writing diagnoses down instead of just fixing things.&lt;/p&gt;

&lt;p&gt;The casualty was "cli", a navigational regression test, and its explain was the best single artifact of the arc. The page titled CLI held kw#3. Ahead of it at kw#1, scoring 4.324, sat a page containing zero occurrences of the word "cli", carried entirely by summed damped prefix expansions: "client", "client-side". And the fused-order arithmetic showed the case had been passing at baseline by 0.032266 to 0.032258.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqtcym7puts7z9a0tl7wn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqtcym7puts7z9a0tl7wn.png" alt="Explain output for the query cli. A page with zero occurrences of the word cli holds kw#1 at score 4.324, carried by client and client-side prefix expansions. The fused scores of the top two results, 0.03227 and 0.03226, differ only in the last digit." width="800" height="244"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A margin of eight millionths. Alpha did not break a solid case. It revealed a case that had been passing by luck, balanced on a coin edge that any keyword up-weight would tip. A regression test passing by 8e-6 is not a passing test; it is a coin that has not been flipped yet. The fix recorded the margin so nobody trusts that case as load-bearing again, and the per-case diff discipline is the only reason the coin was ever noticed before it landed wrong in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two keyword fixes, both defensible with the fixture deleted
&lt;/h2&gt;

&lt;p&gt;The "cli" explain exposed a principle-level bug, not a tuning problem. Completions of a half-typed word are competing hypotheses about what the user means, not corroborating evidence. A document matching several completions of "cli" has not multiplied its evidence for anything. The fix scores the trailing word's hypothesis set, the exact match plus its expansions, as a per-document maximum instead of a sum, while distinct typed words still sum as independent evidence. Confidence deliberately kept summing: it answers "does the keyword side have any evidence at all" rather than "how much does one document have", and its floor was calibrated against that arithmetic.&lt;/p&gt;

&lt;p&gt;Counterfactual analysis before building showed best-of alone would not rescue "cli": with the phantom kw#1 demoted, the install page's genuine description-plus-body evidence still edged the CLI page's lone title hit. The enabler was the second fix, a title-cover tier, an idea from the first brainstorm of the whole arc that finally found its case. Documents whose titles contain every typed query word rank ahead of documents whose titles do not, BM25F order within each tier. Ordinal on purpose, so it composes with rank fusion untouched instead of fighting the saturation curve, and the trailing word can be covered through an expansion, so the tier works mid-keystroke.&lt;/p&gt;

&lt;p&gt;The tier carries one deliberate semantic inversion, pinned by a named test rather than slipped in: for covered queries, title coverage outranks the tag weight. &lt;code&gt;w_tag&lt;/code&gt; encodes topical authority; coverage encodes navigational intent; the navigational claim wins across tiers and the weights decide within. The old "tag outranks title" test was split into its two claims, score arithmetic (unchanged, still pinned) and rank order (now inverted for covered queries, pinned as deliberate). And the safety property got its own test: an all-false cover reproduces the plain ranking byte for byte, so queries the tier does not touch score exactly as they did before.&lt;/p&gt;

&lt;p&gt;Predictions and outcomes, because pre-registering them is the house style: the tier and best-of were predicted to reach 35 with no outcome changes from the tier itself, and 37 with alpha armed. Actual: 36 and 37. The overshoot was best-of alone flipping "rebuild automatically when i publish a post" to PASS, unpredicted, and alpha then taking it back, weighted fusion re-amplifying the sense-collision evidence ("content rebuild" in the cache-invalidation sense) that best-of had demoted. Alpha's honest ledger on the merged set: +2 / -1, net +1, with the casualty being a case whose own diagnosis already prescribes a content fix no fusion weight can substitute for. Armed anyway, with the taketh-away recorded in the case's notes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fixture debt comes due
&lt;/h2&gt;

&lt;p&gt;Running the gate file at the honest configuration for the first time showed two cases down from the recorded 37/37, and the immediate read was "two regressions". The audit said otherwise.&lt;/p&gt;

&lt;p&gt;The 37/37 had been measured at &lt;code&gt;--min-cos 0.34&lt;/code&gt;, the hand-raised floor. The file's own header had warned that "any --fail-under here gates a configuration nobody actually runs", and the negatives section had predicted that these exact two cases were the most likely to flip when the gate started measuring what ships. Both had been failing at every honest configuration all along. Their green was measurement debt, and the honest floor collected it on the predicted schedule. The gate file's true history, restated without the debt: 33/37 at this chapter's start, +2 from the calibration and keyword work, two never-real passes now visible as the failures they always were.&lt;/p&gt;

&lt;p&gt;A fixture file that names, in advance, which of its own passes are fake is doing science. The predictions coming due is what the apparatus was for.&lt;/p&gt;

&lt;p&gt;The litigation round that preceded this settled the open fixture verdicts by reading the actual corpus, and two pre-reading guesses reversed on contact with the pages, which is the argument for the procedure over judgment-from-the-diff. "model2vec-rs" was not widened: the crate name appears in exactly one place in the corpus, the returned winner never mentions it, and blessing it would be blessing a noise winner. "data-chops-open" turned out not to be a fixture problem at all but the fusion case above. "which files land in the deployed site" was widened to include the configuration page, weakly held and recorded as such. The verdict rule that did the work: &lt;strong&gt;would you add this URL to &lt;code&gt;expect&lt;/code&gt; if the case were currently passing?&lt;/strong&gt; Corpus edits made solely to flip a test stayed off the table, with the one carve-out the known-failures file itself blesses: docs fixes that make the docs genuinely better count as documentation work that happens to be measured.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ledger, and the cell I refused
&lt;/h2&gt;

&lt;p&gt;The configuration ladder on the merged 46-case set, all at the derived floor: baseline 34, gate armed 35, keyword fixes 36, alpha armed 37. Baseline and gate pin to &lt;code&gt;42593bf&lt;/code&gt;, the keyword fixes and alpha to &lt;code&gt;c9905d8&lt;/code&gt;, and all four rungs re-measure exactly. 80% recall@1, up from 74% at the chapter's start, with every point traceable to a mechanism: a gate, a tier, a scoring correction, or the measurement getting honest. Zero points came from resweeping weights. The tuning surface was flat; the recall was hiding in the fusion arithmetic, the expansion semantics, the fixtures, and the prose.&lt;/p&gt;

&lt;p&gt;On the gate file: 35/37 at the shipping candidate configuration, and its two failures were exactly the two standing verdicts, which landed: bicycle demoted to the known-failures file on 2026-08-19 (&lt;code&gt;0731290&lt;/code&gt;), where its confession lives, and "which files" widened to include the configuration page on 2026-08-21 (&lt;code&gt;46c27c5&lt;/code&gt;), weakly held and recorded as such. At the chapter's shipping configuration against the settled fixture, the gate reads 36/36: the &lt;code&gt;c9905d8&lt;/code&gt; engine, &lt;code&gt;--min-gap 0.08 --rrf-alpha 1&lt;/code&gt;, fixture as of &lt;code&gt;46c27c5&lt;/code&gt;, 100% at recall@1 and recall@3.&lt;/p&gt;

&lt;p&gt;The engine that started this series at 65% now passes its whole gate, and every case it cannot win lives in the known-failures file with a signed confession.&lt;/p&gt;

&lt;p&gt;And one number I turned down. The configuration matrix contained a tempting 97% cell: everything above plus &lt;code&gt;--min-cos 0.34&lt;/code&gt;, 36/37 at &lt;code&gt;c9905d8&lt;/code&gt;, and it re-measures exactly, casualty included: at that floor, "make the download smaller for visitors" slips to third place in the file the gate cannot read. Rejected, because its casualty lives in the known-failures file, so the gate file cannot see the collateral. Shipping a configuration whose damage is hidden by the file split would be optimizing the scoreboard against the honesty the two-file design exists to protect. The split earns its keep in both directions: it keeps known failures from blocking CI, and it keeps CI from laundering them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The asterisk, and what closed it
&lt;/h2&gt;

&lt;p&gt;Everything above was measured through CLI flags. The browser constructs its scoring from &lt;code&gt;index.bin&lt;/code&gt;, which at that point carried the field weights and nothing else, so live search was still running the baseline column: ungated, unweighted, junk on toddler-class queries. The CI gate was about to certify a configuration nobody's visitor ran, which is the measured-at-0.34 debt in a new denomination.&lt;/p&gt;

&lt;p&gt;The persistence batch closed it. &lt;code&gt;min_gap&lt;/code&gt;, &lt;code&gt;rrf_alpha&lt;/code&gt;, and the &lt;code&gt;min_cos&lt;/code&gt; override became &lt;code&gt;chops-search.toml&lt;/code&gt; keys, ride into &lt;code&gt;index.bin&lt;/code&gt; next to the field weights under one version bump, and get read out by the engine at construction. The config file states the scoring, the build bakes it, the browser runs it, and a bare &lt;code&gt;eval&lt;/code&gt; measures it: one configuration in four places, none of them shell history. The boundary rule that came with it: persist what you calibrated, default what you did not. &lt;code&gt;rrf_k&lt;/code&gt; stays compiled because its sweep was flat; &lt;code&gt;strong_cos&lt;/code&gt; stays off because no case has ever needed the hatch. A knob earns a config key the day a sweep produces a value worth shipping, and on that day it also earns an artifact field, because a config key that does not reach the browser recreates the exact gap the batch closed.&lt;/p&gt;

&lt;p&gt;The final verification is the one this whole series was pointed at: type "toddler bedtime routine" into the live search box and watch it return nothing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fscsjgfhc7hl940mu9o6v.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fscsjgfhc7hl940mu9o6v.gif" alt="The gate, live: an off-topic query returns nothing, a real one still resolves. Recorded against the deployed index." width="760" height="475"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Landing the batch meant rewriting two files, and the review of that rewrite found seven things the new code had quietly stopped doing, none of which any passing test could see. That is its own story, and it is the next post.&lt;/p&gt;

&lt;p&gt;One prediction, pre-registered here so it can fail in public: a comprehensive multi-knob sweep is planned, and the expected result is flat, everywhere, except possibly &lt;code&gt;chunk_penalty&lt;/code&gt;, which has never been swept systematically and is the one knob a future &lt;code&gt;chunk_chars&lt;/code&gt; change could wake up. The coupled grids (alpha crossed with k, min_gap crossed with strong_cos) should reproduce the flat-column result. If that prediction fails, the failure is the finding, and it gets the same write-up a win would.&lt;/p&gt;

&lt;p&gt;The prediction has since been tested, and recording the outcome here keeps the chronology honest, because the paragraph above was written first. A calibration round on 2026-08-14 swept every knob against this gate and woke exactly the knob this post's closing prediction named: &lt;code&gt;chunk_penalty&lt;/code&gt;, pinned at 0.12 the next day (&lt;code&gt;534e1dd&lt;/code&gt;), the same commit that retired the hand-raised floor for good. The sweep regenerates from the repo alone, corpus, fixture, and harness all being committed, and its verdict re-litigates in one command: at the post-verdict fixture, the pin's per-case ledger against the old 0.02 is +1/-0. It wins Content-Range and costs nothing. And two weeks of documentation growth then did what documentation growth does on a dogfooded corpus: today's bare eval at HEAD reads 33/36, three failures whose winners are the freshly written measurement pages out-competing the fixture's expectations, none of them the penalty's fault by the same per-case diff. The gate caught the corpus moving. That is what it is for.&lt;/p&gt;

&lt;p&gt;The engine, the eval harness, both fixture files, and every sweep in this post are in the repo: &lt;a href="https://github.com/gitbadger-clan/chops-search" rel="noopener noreferrer"&gt;github.com/gitbadger-clan/chops-search&lt;/a&gt;. Part one, the evidence-changing work that got the corpus to the plateau this post started from, is &lt;a href="https://unicow.dev/blog/chops-search-eval/" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>testing</category>
      <category>datascience</category>
      <category>ai</category>
    </item>
    <item>
      <title>My search engine scored 65% on its own docs site</title>
      <dc:creator>Artur Daschevici</dc:creator>
      <pubDate>Wed, 26 Aug 2026 12:49:00 +0000</pubDate>
      <link>https://dev.to/adaschevici/my-search-engine-scored-65-on-its-own-docs-site-1cok</link>
      <guid>https://dev.to/adaschevici/my-search-engine-scored-65-on-its-own-docs-site-1cok</guid>
      <description>&lt;p&gt;A couple of weeks ago I launched &lt;a href="https://github.com/gitbadger-clan/chops-search" rel="noopener noreferrer"&gt;chops-search&lt;/a&gt; with a blog post claiming 92% &lt;a href="mailto:recall@1"&gt;recall@1&lt;/a&gt;. That number is real and it still stands: 24 labelled queries, my blog corpus, methodology in the post. Then I wired the engine into its own docs site, added a CI gate to block deploys on eval failures, and ran the eval honestly for the first time.&lt;/p&gt;

&lt;p&gt;65%.&lt;/p&gt;

&lt;p&gt;This is part one of three on what happened next. It ends at a plateau, on purpose: recall@1 stuck in the mid-70s on a fixture that kept growing, every ranking knob swept flat or regressive, and a hypothesis about rank fusion waiting to be tested. Part two is what the test did to the hypothesis.&lt;/p&gt;

&lt;p&gt;The claim this part earns: &lt;strong&gt;every change that moved recall changed what evidence existed for the ranker to see. Every knob that merely reweighted existing evidence went flat or backfired.&lt;/strong&gt; I built four ranking levers during this arc, measured all four, and shipped all four inert. That sentence would have read as an indictment to me a month ago. It is the most useful thing this corpus taught me. (Part two complicates it, with receipts. Evidence changes still win, but reweighting knobs eventually earn their values, one inside part two's arc and one at its very close, and the rule for telling those cases apart from noise is part two's story.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The scoreboard
&lt;/h2&gt;

&lt;p&gt;The query set changed twice during the arc (23 cases grown to 46, plus a gate/known-failures split), so no single percentage pair is an honest before/after. Every checkpoint with its own denominator:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Checkpoint&lt;/th&gt;
&lt;th&gt;Fixture&lt;/th&gt;
&lt;th&gt;recall@1&lt;/th&gt;
&lt;th&gt;Passing&lt;/th&gt;
&lt;th&gt;What changed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First honest run&lt;/td&gt;
&lt;td&gt;23 cases&lt;/td&gt;
&lt;td&gt;65%&lt;/td&gt;
&lt;td&gt;15/23&lt;/td&gt;
&lt;td&gt;Nothing yet. Baseline.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Labels, content, compound tokens, keyword floor&lt;/td&gt;
&lt;td&gt;23 cases&lt;/td&gt;
&lt;td&gt;65%&lt;/td&gt;
&lt;td&gt;15/23&lt;/td&gt;
&lt;td&gt;Diagnosis pass: engine net -1, fixture repair +1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BM25F&lt;/td&gt;
&lt;td&gt;23 cases&lt;/td&gt;
&lt;td&gt;83%&lt;/td&gt;
&lt;td&gt;19/23&lt;/td&gt;
&lt;td&gt;Field-normalized weighting lands&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixture grown for coverage&lt;/td&gt;
&lt;td&gt;46 cases&lt;/td&gt;
&lt;td&gt;76%&lt;/td&gt;
&lt;td&gt;35/46&lt;/td&gt;
&lt;td&gt;Same engine, harder test&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Descriptions as a keyword field&lt;/td&gt;
&lt;td&gt;46 cases&lt;/td&gt;
&lt;td&gt;80%&lt;/td&gt;
&lt;td&gt;37/46&lt;/td&gt;
&lt;td&gt;The description experiment's winner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Honest configuration&lt;/td&gt;
&lt;td&gt;46 cases&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;td&gt;34/46&lt;/td&gt;
&lt;td&gt;Where this part ends&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the passing column, not the percentage column, and read the last two rows together, because the number goes &lt;em&gt;down&lt;/em&gt; on a better engine. What happened there is the floor: rows three through five were measured at a hand-raised &lt;code&gt;min_cos 0.34&lt;/code&gt;, and the last row put it back to its derived default. Some green under that raised floor was measurement debt, passes that only existed at a configuration nobody ships, and the honest configuration collected it. The full audit of that debt is part two. The rule it taught fits in one line here: a gate that measures a configuration nobody runs is not a gate, it is a scoreboard.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr1ynjbq2r8eusodktkvk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr1ynjbq2r8eusodktkvk.png" alt="Passing cases across six checkpoints. Two panels split where the fixture grows from 23 to 46 cases. The final drop from 37 to 34 is annotated as the honest floor collecting measurement debt." width="799" height="383"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every number was measured at &lt;code&gt;dims = 128&lt;/code&gt;. The rows pin to commits: &lt;code&gt;047ebd8&lt;/code&gt; (baseline), &lt;code&gt;588e4e3&lt;/code&gt; (diagnosis pass), &lt;code&gt;7e3e280&lt;/code&gt; (the BM25F row on its own fixtures, and the grown-fixture row against the fixture from &lt;code&gt;2205069&lt;/code&gt;), &lt;code&gt;fc1e235&lt;/code&gt; (descriptions), and &lt;code&gt;42593bf&lt;/code&gt; (honest configuration; the fusion sweep at that commit reproduces this row as its self-check cell). Rows one, two, and six are one &lt;code&gt;git checkout&lt;/code&gt; from reproducible; rows three through five additionally pass &lt;code&gt;--min-cos 0.34&lt;/code&gt;, the floor they were measured under, and rows four and five point eval at the fixture from &lt;code&gt;2205069&lt;/code&gt;, since the grown fixture landed after the engine work in commit order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a docs site is the hostile corpus
&lt;/h2&gt;

&lt;p&gt;The setup: 19 pages, Diátaxis structure, 132 chunks, 1,592 keyword terms at the BM25F commit (1,607 by the descriptions commit and 1,629 at the plateau; the count moved with docs edits during the arc). CI builds the CLI from workspace HEAD, builds the index, and gates deploys on the eval. Dogfooding with teeth.&lt;/p&gt;

&lt;p&gt;A docs site about one tool is topically homogeneous, and homogeneity hurts both halves of a hybrid engine at once:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keyword side: vocabulary collapse.&lt;/strong&gt; Words like "search", "index", "query", "build" appear on every one of the 19 pages. Their IDF is near zero. BM25 has nothing to discriminate with, because the vocabulary that describes the tool is the vocabulary of every page about the tool. The extreme case: "chops" has document frequency 19 of 19. On this corpus, the project's own name is a stopword.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Semantic side: elevated noise floor.&lt;/strong&gt; Every page is "about chops-search", so the cosine between a query and the &lt;em&gt;wrong&lt;/em&gt; pages is uniformly higher than on a diverse corpus. Off-topic queries were clearing the relevance floor with confident nonsense.&lt;/p&gt;

&lt;p&gt;The engine was calibrated on my blog: mixed topics, distinctive vocabulary per post, exactly the corpus where both halves work as designed. The docs corpus is the opposite in every dimension that matters. Not an excuse for 65%, but the variable the whole post turns on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnose before touching any knob
&lt;/h2&gt;

&lt;p&gt;The first move was explicitly not tuning. The ladder, in order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Read the per-kind recall table.&lt;/strong&gt; The red row tells you which subsystem is failing. Exact and navigational failing points at the keyword side. Paraphrase failing points at the semantic side. Negative controls failing means the relevance floor is too permissive for this corpus.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Compare recall@3 to recall@1.&lt;/strong&gt; If @3 is high while @1 is low, the right answers are on the podium and you have a tie-breaking problem. If @3 is also low, retrieval itself is failing, and no threshold sweep will save you. Those are fixed in completely different places.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Audit every failure as a possible labeling question first.&lt;/strong&gt; The docs-specific trap: many queries have two legitimate answers, a flag documented in both a how-to and the CLI reference. &lt;code&gt;expect&lt;/code&gt; is a list and the check is &lt;code&gt;contains&lt;/code&gt;, so both URLs belong in it. I had a handful of cases where the engine returned the reference page and I had labelled the guide page. The engine was right. My labels were wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Only then touch the engine.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One eval UX gotcha that fed a false sense of failure: the URL column in per-case rows shows the top-1 result, not the expectation. On PASS rows they coincide, so you never notice. On failures it shows what beat you. Several of my "failures" were the engine returning a page that legitimately answered.&lt;/p&gt;

&lt;p&gt;The audit also surfaced two real bugs. Hyphenated identifiers like &lt;code&gt;data-chops-open&lt;/code&gt; were split into parts, and with "chops" a corpus-wide stopword, compound-identifier queries were ranked on noise. Fix: emit the whole token alongside its parts. And stopword-only queries were entering rank fusion with junk keyword lists that still vote. Fix: a keyword confidence floor (&lt;code&gt;kw_floor&lt;/code&gt;) that suppresses the list when matched IDF mass falls below a threshold fraction of potential mass, with the trailing word exempt from the denominator when it produced prefix expansions, so mid-typing stays live while completed junk gets gated.&lt;/p&gt;

&lt;p&gt;I remembered that pass as a seventeen-point jump. The committed history says otherwise, and the correction is the most on-theme finding in this post: at any held-constant configuration, the engine side of the diagnosis pass was net minus one case. It won exactly the case it targeted, &lt;code&gt;data-chops-open&lt;/code&gt; flipping top3 to PASS in the per-case diff, the compound-token fix visibly doing its job, and it paid two collateral cases. Everything else I had attributed to the pass lived in fixture repair (the label widening is worth one case in every configuration), in content fixes that had not been committed yet, and in a relevance floor that would not exist for another two days. The scoreboard above is the committed version, and the diagnosis ladder below is still the right procedure; what it produced first was better labels and better docs, not a better ranker, which is precisely this post's thesis wearing its own clothes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fixture split.&lt;/strong&gt; I'll be straight about the motive: I wanted CI green, and a set of diagnosed engine issues stood between me and green. Quarantining failures to pass a gate is the classic way to lie to yourself, so the split only works with discipline attached. &lt;code&gt;fixtures/queries.toml&lt;/code&gt; is the contract the engine meets today, gating CI. &lt;code&gt;fixtures/known-failures.toml&lt;/code&gt; holds annotated cases reproducing open issues, each with a diagnosis and candidate fix in a comment, runnable manually, not gating. The rule is written into the file header: when a fix lands, promote its case back in the same PR, and never "fix" a case by blessing whatever the current noise winner is in &lt;code&gt;expect&lt;/code&gt;. A gate you tuned to green on a small set is a gate that lies. This one has a paper trail instead. (Part two shows the paper trail doing something I did not expect: predicting its own future, twice.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The work that moved the number
&lt;/h2&gt;

&lt;p&gt;Three changes account for most of the movement in this part, and all three share a property: they put evidence in front of the ranker that it previously could not see.&lt;/p&gt;

&lt;h3&gt;
  
  
  Field weights were doing the wrong thing, confidently
&lt;/h3&gt;

&lt;p&gt;The keyword engine had supported field weights from early on. A title mention counted like two body mentions, a tag like four, and the builder implemented that literally: it multiplied the term frequency before writing the posting.&lt;/p&gt;

&lt;p&gt;That is wrong in a way that is easy to miss, because the direction is right. BM25 saturates term frequency against &lt;code&gt;k1&lt;/code&gt; (1.2 by default). The saturation curve is what stops a document that says "caching" forty times from scoring forty times higher than one that says it once. Pre-multiplying tf pushes a term straight past the interesting part of that curve: a single tag occurrence entered scoring as tf 4, already deep into saturation, so the distinction between "tagged with this" and "tagged with this and about it" disappeared. Worse, the inflated tf also inflated the document's computed length, so weighting one field quietly penalized every other term on the page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwty65977srvn7tnqq05x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwty65977srvn7tnqq05x.png" alt="BM25 term frequency saturation curve. A tag occurrence pre-multiplied to tf 4 enters deep in the flat region. A field-normalized tf enters on the steep part of the curve where differences still score." width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;BM25F is the standard answer and it inverts the order of operations. Each field carries its own term frequency and its own average length. Each field's tf is normalized by that field's length, the normalized values combine under the weights, and saturation applies once to the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ntf_f  = tf_f / (1 - b + b * dl_f / avg_f)
tfw    = w_title * ntf_title + w_tag * ntf_tag + w_desc * ntf_desc + ntf_body
score += idf * tfw * (k1 + 1) / (tfw + k1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The practical difference: a term in a five-word title is now scored against the average title, not against two thousand words of body. That is the entire reason a query like "artifacts" can pick the page titled Artifacts out of five pages that use the word in prose. By the next checkpoint, navigational recall measured 100%, and it stayed there through the plateau.&lt;/p&gt;

&lt;p&gt;Two implementation notes that turned into rules. The posting record widened twice on the way here: per-field term frequencies took it from four bytes (doc, tf) to eight, and the description field below takes it to ten. Each widening forces a format version bump, and the committed history reads v1 to v3 to v4; the mid-session v2 never landed, a small sibling of the phantom 44. The bump is not for tidiness, but because a reader one version behind would parse the newer file into plausible garbage rather than failing, since every field is a u16 and nothing about the byte stream announces its own shape. And the weights moved from build time to query time, persisted in &lt;code&gt;index.bin&lt;/code&gt;, so they can be swept against an existing index with &lt;code&gt;eval --w-title&lt;/code&gt; instead of a rebuild per value, and the browser scores with whatever the site configured. That introduced a new way to be wrong: any caller that builds a &lt;code&gt;ScoreOpts::default()&lt;/code&gt; instead of starting from &lt;code&gt;engine.score_opts()&lt;/code&gt; silently discards the index's own weights. That is now a test.&lt;/p&gt;

&lt;h3&gt;
  
  
  The description experiment
&lt;/h3&gt;

&lt;p&gt;Nineteen pages, every one with a hand-written &lt;code&gt;description&lt;/code&gt; in its front matter, none of them indexed. The descriptions read exactly like the questions a searcher asks: "rebuilds the search index whenever content changes", "why chops-search degrades to keyword-only". Three of four failing paraphrase cases had their answer sitting in an unindexed field. The obvious move was to index it, and the obvious place was the semantic side, since descriptions are natural language rather than rare identifiers.&lt;/p&gt;

&lt;p&gt;The obvious place was wrong. Five configurations, same 46 cases, all at &lt;code&gt;--min-cos 0.34&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where the description goes&lt;/th&gt;
&lt;th&gt;recall@1&lt;/th&gt;
&lt;th&gt;exact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nowhere (baseline)&lt;/td&gt;
&lt;td&gt;74%&lt;/td&gt;
&lt;td&gt;86%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Synthetic chunk 0, with title and tags&lt;/td&gt;
&lt;td&gt;72%&lt;/td&gt;
&lt;td&gt;79%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chunk 0 and keyword field&lt;/td&gt;
&lt;td&gt;76%&lt;/td&gt;
&lt;td&gt;86%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keyword field only&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;80%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;93%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Its own semantic chunk, plus keyword&lt;/td&gt;
&lt;td&gt;78%&lt;/td&gt;
&lt;td&gt;86%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keyword field, tf counted twice&lt;/td&gt;
&lt;td&gt;80%&lt;/td&gt;
&lt;td&gt;93%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every row is measured on the 46-case fixture. The keyword rows are &lt;code&gt;--w-desc 0&lt;/code&gt;, &lt;code&gt;1&lt;/code&gt;, and &lt;code&gt;2&lt;/code&gt; at &lt;code&gt;fc1e235&lt;/code&gt; (descriptions reach the keyword engine as a BM25F field there, which is this table's "keyword side"); the two placement rows are one-commit experiment branches atop &lt;code&gt;fc1e235&lt;/code&gt;, pinned at &lt;code&gt;bea4a0b&lt;/code&gt; (chunk 0) and &lt;code&gt;6e93675&lt;/code&gt; (own chunk). The "tf counted twice" run reproduced the "keyword field only" per-case lines byte for byte. One case separates this table's baseline (34) from scoreboard row four (35), and the commits explain it: row four is &lt;code&gt;7e3e280&lt;/code&gt;, before the description field existed, while this baseline is &lt;code&gt;fc1e235&lt;/code&gt; with the field present but disarmed, and the format and chunking changes between the two commits carry the case.&lt;/p&gt;

&lt;p&gt;Every configuration that put description text into an embedding lost exact cases: 13 of 14 with descriptions on the keyword side alone, 11 or 12 in every cell with an embedding placement. And the recall@1 column says something sharper than "keyword won": pure embedding placement lands &lt;em&gt;below&lt;/em&gt; doing nothing (33 cases against the baseline's 34), and stacking an embedding placement on top of the keyword field only gives back cases the keyword field had won (35 and 36 against keyword-only's 37). The embedding contribution is negative in every cell it appears in.&lt;/p&gt;

&lt;p&gt;The mechanism is short synthetic chunks. An embedding is the mean of its token vectors, so a twenty-word chunk is dominated by those twenty words in a way a six-hundred-word chunk never is. Title plus tags is fine there: eight words that name the page produce a vector pointed at the page. A description is summary prose, and across a documentation corpus every summary is written in the same register. "The headers file for Cloudflare and Netlify, the CSP directives, and the three gotchas." "Fetch, lock, and verify the model2vec model." Those sentences are more similar to each other than the pages they describe are. Giving every document a compact vector pointed at its own summary clusters all nineteen documents together and hands topical pages a strong best-chunk score on queries they merely share vocabulary with. The keyword engine has the opposite property: it cares which specific rare words appeared, and a description contributes a handful of high-idf terms the prose never used.&lt;/p&gt;

&lt;p&gt;The finding, stated the way it is worth remembering: &lt;strong&gt;the field that reads most like a search query is the field the vector engine handles worst.&lt;/strong&gt; Summary prose is low-variance across a corpus. Low variance is poison for a similarity measure and harmless for an inverted index.&lt;/p&gt;

&lt;p&gt;Descriptions now live as their own BM25F field at &lt;code&gt;w_desc = 1.0&lt;/code&gt;. Worth exactly three cases: 34 to 37 on the 46-case set, re-measured, six and a half points. And 1 versus 2 is byte-identical per case, re-measured too, which is the saturation math saying the signal is presence, not weight. The two-line experiment that established weight-insensitivity is also what avoided building a heavier mechanism first and justifying it afterward.&lt;/p&gt;

&lt;h2&gt;
  
  
  The knobs that could not reach
&lt;/h2&gt;

&lt;p&gt;Now the other half of the ledger. Four ranking levers built, measured, and shipped inert or at defaults in this part of the arc, each with a recorded reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;chunk_penalty&lt;/code&gt;: structurally inert on this corpus.&lt;/strong&gt; Three remaining failures were won by the same page, a getting-started tutorial that touches install, model, build, and serve, and therefore has a chunk near almost any query. That is precisely the bias this knob exists to correct: max-pooling over &lt;code&gt;n&lt;/code&gt; chunks is a biased estimator, and the correction subtracts &lt;code&gt;coeff * sqrt(2 ln n)&lt;/code&gt;. Sweeping 0.02 and 0.04 produced byte-identical per-case runs. At 0.06 exactly one case moved: "which files land in the deployed site" slid from the caching how-to to the configuration reference, two pages that both answer it, and part two records the fixture widening to say so. The arithmetic explains both halves: what reorders two documents is the difference between their penalties, and for a twelve-chunk document against a five-chunk one that difference is sqrt(2 ln 12) - sqrt(2 ln 5) = 0.44, which at coeff = 0.02 is 0.009 of cosine against rank-gaps of 0.02 to 0.05 on this corpus, and at 0.06 is 0.026, just inside the band, where it found the one pair sitting at the bottom of it. Below roughly 0.05 the knob cannot reach. The reason is corpus shape: 132 chunks over 19 documents is seven per document, and a correction that scales with the log of a count needs the count to vary. It was designed against a blog corpus where a 23-chunk essay competed with a one-page note. Here every page is a screen of prose, so at the default and twice the default it is structurally inert, and the first thing it reaches when pushed is a label, not a ranking. Not mistuned, inert here. Different diagnoses: a mistuned knob wants a sweep, an inert one wants to be left at default and understood. And the same arithmetic that convicts the range names the escape: the differential scales linearly with the coefficient, so at roughly six times the default it clears the top of the observed rank-gap band. "Inert" is a claim about a range, not a knob, and part two's closing records what happened when a later sweep went where this one did not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;min_gap&lt;/code&gt; / &lt;code&gt;strong_cos&lt;/code&gt;: built for a real problem, shipped disarmed.&lt;/strong&gt; An earlier experiment at higher dimensionality broke both negative controls, and the failure taught me the shape of the problem: on a homogeneous corpus, the signature of noise is flatness, not low absolute cosine. An off-topic query produces a flat pack where every doc scores 0.15 to 0.35 with no standout. A real paraphrase produces one document clearly above the field. So I built a corroboration gate on the relative statistic &lt;code&gt;top_median_gap&lt;/code&gt;, suppressing an uncorroborated semantic list only when the field is flat and nothing is strongly relevant in absolute terms. In this part of the arc it ships with both knobs disarmed (&lt;code&gt;min_gap&lt;/code&gt; 0, &lt;code&gt;strong_cos&lt;/code&gt; off, an asymmetry documented on the fields because it is exactly what someone "cleans up" later). What ships armed is the diagnostics: &lt;code&gt;--explain&lt;/code&gt; prints gap and top, so that if this gate is ever needed, calibrating it is a lookup rather than a sweep. It turned out to be needed sooner than expected. Part two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;rrf_alpha&lt;/code&gt;: built, first sweep regressive, ships at 0.&lt;/strong&gt; Confidence-weighted rank fusion cost points at every armed value on the first pass. Its story is not over either, and the way it comes back is the best argument in the whole arc for keeping measured dead ends written down. Part two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;w_tag&lt;/code&gt;: flat above zero.&lt;/strong&gt; Every one of the 19 pages carries hand-curated tags under [taxonomies], and the field is live: at 42593bf, w_tag 0 costs one case, "shrink vectors to save bandwidth", a page that is found by its tags when the weight is armed and by nothing else when it is not. 2, 4, and 8 are byte-identical per case. Presence is the signal and weight is not, the same shape the description field showed, and the same reason a sweep across the armed range reads flat. The default of 4 is inherited; the sweep says any value above zero would do, and the config comment now says that instead of implying the number was earned.&lt;/p&gt;

&lt;p&gt;Four levers shipped inert. Not waste: each has a recorded reason, and three of them ruled out a hypothesis about one stubborn failure that I would otherwise have re-tried in a month. A measured dead end you wrote down is cheaper than the same dead end explored twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two methodology lessons, learned the hard way
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Summary-level A/B testing hid a cancellation.&lt;/strong&gt; The description experiment initially ran two changes in one commit: text into synthetic chunk 0, and terms into the keyword body. The summary read 77%, then 75%, then 77% again, which looked like a change that did nothing. It was two changes pulling in opposite directions, each worth one to two cases, netting to zero, and I read "descriptions do not help this corpus" off the summary line twice. What exposed it was diffing per-case lines instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;chops-search &lt;span class="nb"&gt;eval&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(PASS|top3|FAIL)'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/a.txt
&lt;span class="c"&gt;# change exactly one thing, rebuild&lt;/span&gt;
chops-search &lt;span class="nb"&gt;eval&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(PASS|top3|FAIL)'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/b.txt
diff /tmp/a.txt /tmp/b.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A one-line diff is a real result. An empty diff is a real result. An unchanged summary is not, because it cannot distinguish "nothing happened" from "two things happened and cancelled".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmmxqu9q3d2o6o5jweyoo.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmmxqu9q3d2o6o5jweyoo.gif" alt="Terminal capture: eval output grepped to a file, one config change, second run, diff prints exactly two changed lines." width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Growing the fixture lowered the score, which was the point.&lt;/strong&gt; The set went from 23 cases to 46, chosen for coverage rather than for passing: every indexed page became the primary expectation of at least one case, which closed three pages nothing had been asking about. Recall@1 read 83% before and 76% after (19/23 to 35/46, same engine, same floor, both re-measured), on the same engine facing a fixture that finally asked about everything. The denominator changed and the honest response is to say so, which is what the scoreboard at the top of this post is for. The growth also flushed out fixture bugs wearing ranking-bug clothes: one case handed a distinctive word to a wrong-but-plausible page (fix: reword the case), another asked a question whose only answer lives in a section landing page the indexer skips by design (fix: delete the case, or improve the docs for their own sake). The distinction matters. Editing content so a test passes is only legitimate when the content is better afterward. Otherwise it is fitting the corpus to the fixture, which is the same failure as fitting thresholds to the fixture, wearing a different hat.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plateau
&lt;/h2&gt;

&lt;p&gt;Where part one ends: 46 cases, honest configuration, 34 passing. 74% recall@1, 87% recall@3, both re-measured at &lt;code&gt;42593bf&lt;/code&gt; with no flags (34 passing, 40 within the top 3).&lt;/p&gt;

&lt;p&gt;That thirteen-point gap between @1 and @3 is the tell. The right page is almost always retrieved. It is just not first. Which means the remaining problem lives wherever "first" gets decided, and in a hybrid engine that place is rank fusion. So I had a hypothesis, and it was attractive: RRF's conventional &lt;code&gt;k = 60&lt;/code&gt; is calibrated for TREC-scale runs fusing thousand-deep lists. On a 19-document corpus the reciprocal-rank curve is nearly flat (rank 1 versus rank 2 is 1/61 versus 1/62, a gap of 0.0003), so fusion degenerates toward "best average rank wins" and a decisive first place in one engine cannot survive mediocrity in the other. That is exactly the @1-fails, @3-passes signature. Nobody calibrates k. It looked like the constant nobody had checked.&lt;/p&gt;

&lt;p&gt;I built a sweep to test it: a full grid over &lt;code&gt;rrf_k&lt;/code&gt; and &lt;code&gt;rrf_alpha&lt;/code&gt;, every cell running all 46 cases, with the &lt;code&gt;k=60 / alpha=0&lt;/code&gt; cell required to reproduce the baseline exactly as a self-check on the harness.&lt;/p&gt;

&lt;p&gt;What the grid did to the hypothesis, what the per-kind table said instead, a regression test that turned out to be passing by a margin of eight millionths, and the fixture file that predicted its own future: part two.&lt;/p&gt;

&lt;p&gt;The engine, the eval harness, and both fixture files with their header rules are in the repo: &lt;a href="https://github.com/gitbadger-clan/chops-search" rel="noopener noreferrer"&gt;github.com/gitbadger-clan/chops-search&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>deeplearning</category>
      <category>testing</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Semantic search on a static site with no server: range-fetching the embedding model</title>
      <dc:creator>Artur Daschevici</dc:creator>
      <pubDate>Mon, 10 Aug 2026 15:31:09 +0000</pubDate>
      <link>https://dev.to/adaschevici/semantic-search-on-a-static-site-with-no-server-range-fetching-the-embedding-model-3f6p</link>
      <guid>https://dev.to/adaschevici/semantic-search-on-a-static-site-with-no-server-range-fetching-the-embedding-model-3f6p</guid>
      <description>&lt;p&gt;The search box on my site downloads about 0.1 KB per query. That number sounded wrong to me too, so I put the devtools network tab in the demo GIF. Watch the range requests. That's the whole trick.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4cmad6kxnh522q0gjvem.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4cmad6kxnh522q0gjvem.gif" alt=" " width="600" height="850"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There's no search server behind it. No SaaS, no API key, no request leaving the page except range fetches against static files sitting next to the HTML. The engine is called chops-search, it's on crates.io, and the search on the &lt;a href="https://chops-search.gitbadger.com/tutorials/getting-started/" rel="noopener noreferrer"&gt;docs site&lt;/a&gt; is a live deployment you can poke at. This post is about the one architectural decision that made it work: treating the embedding model as a file you read byte offsets out of, instead of a blob you download.&lt;/p&gt;

&lt;p&gt;You can check the claim from your terminal without trusting my recording:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code} %{size_download} bytes&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Range: bytes=0-127"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://chops-search.gitbadger.com/search/model.rows.2b9eb020575d2857.i8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That hash in the filename is a content hash, and every rebuild mints a new one. If this URL 404s by the time you read it, grab the current name from manifest.json in the same directory. The hash is what lets the artifacts ship under immutable cache headers, so it is doing real work, not just breaking my curl example.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem, stated plainly
&lt;/h2&gt;

&lt;p&gt;Static sites and search have a long, awkward history. Keyword search is a solved problem: Pagefind figured out how to fragment an index so the browser only pulls what a query needs, and it works beautifully. But keyword search fails on paraphrase. Search my site for "packing a repo into a prompt" and a pure keyword engine shrugs, because the post says "context packing" and never uses your words.&lt;/p&gt;

&lt;p&gt;Semantic search fixes that, and every existing answer to "semantic search on a static site" is some flavor of the same compromise. Run a server. Pay a SaaS. Or ship the entire embedding model to the browser and ask your visitors to download tens of megabytes before they can type.&lt;/p&gt;

&lt;p&gt;I didn't want any of those. I wanted Pagefind's loading discipline applied to the model itself.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why that's even possible
&lt;/h2&gt;

&lt;p&gt;The reason this works is model2vec. A potion-base-8M model isn't a transformer at inference time. It's a lookup table: one static vector per vocabulary token, and a sentence embedding is just the mean of its token rows. No attention, no layers, no runtime beyond "look up rows, average them."&lt;/p&gt;

&lt;p&gt;A lookup table has a property transformers don't: you can read one row without the others. If a query tokenizes to six tokens, you need six rows. At int8 quantization, a row is &lt;code&gt;dim&lt;/code&gt; bytes, and row &lt;code&gt;i&lt;/code&gt; lives at byte &lt;code&gt;i × dim&lt;/code&gt;. That's an HTTP range request. The model stops being a download and becomes an address space.&lt;/p&gt;
&lt;h2&gt;
  
  
  Four files, four loading rules
&lt;/h2&gt;

&lt;p&gt;The build tool emits four artifacts, and each one has a different answer to "when does the browser fetch this."&lt;/p&gt;

&lt;p&gt;&lt;code&gt;model.meta.bin&lt;/code&gt; holds the complete vocabulary plus per-row quantization scales. Around 500 KB, and it gzips hard. This one is never partial. A truncated vocab doesn't fail loudly, it tokenizes wrong and gives you confidently bad embeddings, so completeness is the rule here.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;model.prefix.i8&lt;/code&gt; is the top ~2048 rows by token frequency, loaded eagerly. Common tokens cover a lot of real queries, so most lookups never leave this block.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;model.rows.i8&lt;/code&gt; is the full matrix as headerless raw i8. No framing, no metadata, just bytes, because the offset arithmetic is the format. This is the file the browser range-fetches per query.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;index.bin&lt;/code&gt; carries the chunk vectors, document table, and keyword postings for the site content itself.&lt;/p&gt;

&lt;p&gt;At query time a Web Worker asks the wasm engine which byte ranges it's missing, fetches them, feeds them back, and renders ranked results. Fetched rows go into a Cache API row cache, and the artifacts ship content-hashed under immutable cache headers. So the second time anyone searches for anything vaguely similar, the network doesn't get involved at all.&lt;/p&gt;
&lt;h2&gt;
  
  
  The numbers, with their caveats attached
&lt;/h2&gt;

&lt;p&gt;On my 24-query labelled eval set: 92% recall@1 and 100% &lt;a href="mailto:recall@3"&gt;recall@3&lt;/a&gt;. The set covers exact matches, paraphrases, navigational queries, and a negative control that must return nothing. Exact and navigational sit at 100% &lt;a href="mailto:recall@1"&gt;recall@1&lt;/a&gt;. The two misses are both paraphrase queries, and both still put the right document in the top 3.&lt;/p&gt;

&lt;p&gt;Caveats, next to the claim where they belong: 24 queries is small, I wrote and labelled them myself, and they run against my own site's content. This is a regression gate, not a benchmark against anyone else. It exists so a ranking change can't quietly make things worse, and it runs in CI against a real demo Zola site whose worst page (one post covering thirty unrelated topics) is deliberately kept in as the chunker's stress test.&lt;/p&gt;

&lt;p&gt;The traffic numbers are the part I actually care about. After initial load, 16 of the 24 eval queries need no network at all, either a prefix hit or a warm row cache. The remaining eight average 0.1 KB range-fetched, 0.8 KB worst case. Compare that to shipping the matrix eagerly and the whole design justifies itself.&lt;/p&gt;
&lt;h2&gt;
  
  
  Ranking, briefly
&lt;/h2&gt;

&lt;p&gt;Retrieval fuses BM25 (with length normalisation) and cosine similarity over embedded chunks via reciprocal rank fusion. A relevance floor suppresses junk when neither engine is confident, and the trailing query term gets prefix matching so results show up mid-word while you type. Snippets come from the best-scoring chunk with query terms highlighted.&lt;/p&gt;

&lt;p&gt;There's a &lt;code&gt;chops-search query --explain&lt;/code&gt; command that prints the evidence behind a ranking: keyword scores, best-chunk cosine, each engine's RRF contribution per document. It calls the same scoring code as the ranker, so the explanation cannot drift from the behavior. I built it to debug my own fusion weights and kept it because a search engine that can't show its work is a search engine you can't tune.&lt;/p&gt;
&lt;h2&gt;
  
  
  One tokenizer, enforced structurally
&lt;/h2&gt;

&lt;p&gt;The core crate, &lt;code&gt;chops-search-core&lt;/code&gt;, is pure Rust with no I/O: WordPiece tokenizer, int8 row store, scoring, RRF, artifact formats. It compiles unchanged to native for the build CLI and to wasm for the browser. The tokenizer that indexed your content is bit-for-bit the tokenizer that handles queries, because it's the same compiled code, not two implementations someone promised to keep in sync.&lt;/p&gt;

&lt;p&gt;A parity test drives fixture sentences through my implementation and through MinishLab's official model2vec-rs, asserting cosine &amp;gt; 0.9999 per input. If my tokenizer or quantization drifts from the reference, CI fails. The guarantee is structural, not aspirational. That distinction deserves its own post, and it'll get one.&lt;/p&gt;
&lt;h2&gt;
  
  
  When it breaks, it says so
&lt;/h2&gt;

&lt;p&gt;Partial loading creates a failure mode most search libraries don't have: what if a row you need isn't loaded? Offline, a strict CSP, a host that ignores range requests.&lt;/p&gt;

&lt;p&gt;The wrong answer is to average the rows you have. That produces a shrunken, wrong embedding that returns plausible-looking garbage, which is worse than returning nothing. So &lt;code&gt;embed()&lt;/code&gt; returns nothing. Search degrades to keyword-only and reports that it did, so the UI can tell the user semantic matching is off rather than silently getting dumber. A range-hostile server degrades to eager loading of the full matrix. Slower, not broken.&lt;/p&gt;

&lt;p&gt;I spend a fair amount of my time deliberately breaking data pipelines to see what they do. Building the degradation paths on purpose, instead of discovering them in an issue tracker, was the part of this project that felt most like that work.&lt;/p&gt;
&lt;h2&gt;
  
  
  One gotcha worth flagging
&lt;/h2&gt;

&lt;p&gt;There's a &lt;code&gt;--dims&lt;/code&gt; flag that reduces dimensionality at build time, and it re-runs PCA on the token matrix rather than truncating columns. Potion models are trained after model2vec's distillation-time PCA, so the columns aren't ordered by variance anymore and naive truncation is silently wrong. Cost me an evening. Short follow-up post coming.&lt;/p&gt;
&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The live demo is the search box on &lt;a href="https://unicow.dev/blog/" rel="noopener noreferrer"&gt;my blog&lt;/a&gt;, so the fastest way to evaluate it is to search there for something I've written about, phrased in words I didn't use.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo &lt;span class="nb"&gt;install &lt;/span&gt;chops-search
chops-search init    &lt;span class="c"&gt;# initializes the required static files in your Zola site&lt;/span&gt;
chops-search build   &lt;span class="c"&gt;# walks a Zola content tree, emits the artifacts&lt;/span&gt;
chops-search &lt;span class="nb"&gt;eval&lt;/span&gt;    &lt;span class="c"&gt;# runs your labelled queries as a regression gate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It's a Rust workspace (&lt;code&gt;chops-search-core&lt;/code&gt;, &lt;code&gt;chops-search-cli&lt;/code&gt;, &lt;code&gt;chops-search-wasm&lt;/code&gt;), dual-licensed MIT/Apache-2.0. Zola is the only site generator with first-class support right now because it's what I run. If you want it for Hugo or Eleventy, open an issue and tell me what your front matter looks like.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/gitbadger-clan" rel="noopener noreferrer"&gt;
        gitbadger-clan
      &lt;/a&gt; / &lt;a href="https://github.com/gitbadger-clan/chops-search" rel="noopener noreferrer"&gt;
        chops-search
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Hybrid semantic + keyword search for static sites. Runs entirely in the browser via WASM — no server, no API keys.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;chops-search&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;Hybrid keyword + semantic search for static sites, running entirely in the
browser. No API keys and no server.&lt;/p&gt;
&lt;p&gt;The "model" is a model2vec/potion int8 lookup table streamed over HTTP
range requests, so a query fetches a few 128-byte rows rather than the
23 MB a transformer would: on the project's own docs site the mean is
71 bytes per query and most queries fetch nothing at all (measured with
&lt;code&gt;chops-search plan&lt;/code&gt;, see below). The engine is one Rust core compiled
twice: natively for the build tool, to wasm for the browser. That means
the tokenizer indexing your content is the same code that tokenizes
queries.&lt;/p&gt;
&lt;p&gt;On the demo corpus (9 posts, 24 labelled queries):&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;query kind&lt;/th&gt;
&lt;th&gt;recall@1&lt;/th&gt;
&lt;th&gt;recall@3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;exact (&lt;code&gt;chromedp iframes&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;paraphrase (&lt;code&gt;how long will this project take&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;82%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;navigational (&lt;code&gt;about&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;negative (&lt;code&gt;sourdough starter hydration&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;…&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/gitbadger-clan/chops-search" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>rust</category>
      <category>webassembly</category>
      <category>jamstack</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Choosing what the model sees: a repo-to-prompt tool for 2026</title>
      <dc:creator>Artur Daschevici</dc:creator>
      <pubDate>Wed, 29 Jul 2026 13:17:00 +0000</pubDate>
      <link>https://dev.to/adaschevici/choosing-what-the-model-sees-a-repo-to-prompt-tool-for-2026-46ai</link>
      <guid>https://dev.to/adaschevici/choosing-what-the-model-sees-a-repo-to-prompt-tool-for-2026-46ai</guid>
      <description>&lt;p&gt;For about a week I told people my tool packed the Next.js monorepo in 7 to 10 seconds. The real number was 1.6.&lt;/p&gt;

&lt;p&gt;I was reading the wrong line of the benchmark output. &lt;code&gt;time&lt;/code&gt; gives you user time and wall time, and I'd been quoting user time, which is CPU-seconds summed across cores. My tool runs on five cores. So I was underselling it by 5x, confidently, to anyone who asked. If you take one thing from this post: wall clock is the number humans experience. Quote that one.&lt;/p&gt;

&lt;p&gt;Anyway. This is a post about a repo-to-prompt tool. Yes, in 2026. Stay with me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The awkward timing question first
&lt;/h2&gt;

&lt;p&gt;Repo-to-prompt tools had their moment two years ago, when the workflow was "assemble context by hand, paste it into a chat window, pray." Then agents learned to grep. Claude Code and Cursor pick their own context now, and most people stopped running a packer before every session.&lt;/p&gt;

&lt;p&gt;So why build one?&lt;/p&gt;

&lt;p&gt;Because sometimes the agent's retrieval is exactly what I don't want. Code review across a whole subsystem. A migration plan. A raw API call where nobody is doing retrieval for me. In those moments I want to pick the files myself, see what it costs in tokens, and know that &lt;code&gt;.env.production&lt;/code&gt; didn't tag along.&lt;/p&gt;

&lt;p&gt;Three requirements. Fast. Interactive with visible token costs. Secret-scanned.&lt;/p&gt;

&lt;p&gt;No tool had all three. The fast ones (yek, repomix-rs) are fire-and-forget: point, dump, done. The interactive one (code2prompt) doesn't show token costs. So I forked code2prompt and built &lt;strong&gt;gnaw&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The benchmark
&lt;/h2&gt;

&lt;p&gt;Vendor benchmarks are junk by default. Different machines, cherry-picked flags, and my favorite trick: comparing against output nobody checked. A tool that emits half the files in half the time isn't fast. It's incomplete.&lt;/p&gt;

&lt;p&gt;So here's the setup, built to survive you rerunning it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker, &lt;code&gt;--cpus 8 --memory 8g&lt;/code&gt;. One command, same environment as mine.&lt;/li&gt;
&lt;li&gt;Corpus: &lt;code&gt;vercel/next.js&lt;/code&gt; at &lt;a href="https://github.com/vercel/next.js/commit/70e3934fc6acc4b56a764c4060fee4b55f849aa3" rel="noopener noreferrer"&gt;&lt;code&gt;70e3934&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;hyperfine, 10 runs, warmup, mean ± σ.&lt;/li&gt;
&lt;li&gt;File-count check: every tool must emit within ~1% of the others or the comparison is void. All landed between 26.6k and 26.9k files.&lt;/li&gt;
&lt;li&gt;repomix-rs built from source at &lt;code&gt;5798dc0&lt;/code&gt;, not the npm package. The npm build is roughly 2x slower. I benchmarked their engine, not their packaging. If anything this makes my numbers look worse. Good.&lt;/li&gt;
&lt;li&gt;Scan settings, stated exactly because it matters: three of the five tools secret-scan (gnaw, repomix, repomix-rs). Run 1 turns scanning off where it can be turned off. repomix-rs scans by default and I found no way to disable it, so its run-1 number includes its scan. Which means run 1 is not a clean gnaw-vs-repomix-rs comparison, and I won't quote it as one. Run 2 is the clean one: everything that can scan, scanning.&lt;/li&gt;
&lt;li&gt;I ran this three times across three days while working on the tool. Absolute times wobbled about ±6% with the host's mood. The ratios barely moved. Trust the ratios; treat the absolutes as "this hardware, that day."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Script and Dockerfile are in the repo. Rerun it. I mean it. My least favorite genre of reply is "works differently on my machine" and I'd rather get it as a GitHub issue with numbers attached.&lt;/p&gt;

&lt;h3&gt;
  
  
  Run 1: extraction, scanning off where possible
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;tool&lt;/th&gt;
&lt;th&gt;mean ± σ&lt;/th&gt;
&lt;th&gt;peak RSS&lt;/th&gt;
&lt;th&gt;cpu×&lt;/th&gt;
&lt;th&gt;files&lt;/th&gt;
&lt;th&gt;version&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;gnaw&lt;/td&gt;
&lt;td&gt;1.55 ± 0.10 s&lt;/td&gt;
&lt;td&gt;378 MB&lt;/td&gt;
&lt;td&gt;5.2&lt;/td&gt;
&lt;td&gt;26,706&lt;/td&gt;
&lt;td&gt;local&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;repomix-rs*&lt;/td&gt;
&lt;td&gt;1.73 ± 0.11 s&lt;/td&gt;
&lt;td&gt;311 MB&lt;/td&gt;
&lt;td&gt;4.8&lt;/td&gt;
&lt;td&gt;26,635&lt;/td&gt;
&lt;td&gt;git:5798dc0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;code2prompt&lt;/td&gt;
&lt;td&gt;2.11 ± 0.22 s&lt;/td&gt;
&lt;td&gt;463 MB&lt;/td&gt;
&lt;td&gt;4.1&lt;/td&gt;
&lt;td&gt;26,706&lt;/td&gt;
&lt;td&gt;4.3.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;yek&lt;/td&gt;
&lt;td&gt;8.05 ± 0.20 s&lt;/td&gt;
&lt;td&gt;228 MB&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;26,790&lt;/td&gt;
&lt;td&gt;git:0.25.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;repomix&lt;/td&gt;
&lt;td&gt;35.44 ± 1.87 s&lt;/td&gt;
&lt;td&gt;769 MB&lt;/td&gt;
&lt;td&gt;1.1&lt;/td&gt;
&lt;td&gt;26,928&lt;/td&gt;
&lt;td&gt;
&lt;a href="mailto:repomix@1.16.0"&gt;repomix@1.16.0&lt;/a&gt;, incl. Node startup&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;*includes its default regex scan, which can't be disabled. See run 2 for the equal-footing comparison.&lt;/p&gt;

&lt;h3&gt;
  
  
  Run 2: scanning on, for the three tools that can
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;tool&lt;/th&gt;
&lt;th&gt;mean ± σ&lt;/th&gt;
&lt;th&gt;peak RSS&lt;/th&gt;
&lt;th&gt;cpu×&lt;/th&gt;
&lt;th&gt;scanner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;gnaw&lt;/td&gt;
&lt;td&gt;1.76 ± 0.13 s&lt;/td&gt;
&lt;td&gt;477 MB&lt;/td&gt;
&lt;td&gt;5.2&lt;/td&gt;
&lt;td&gt;gitleaks ruleset&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;repomix-rs&lt;/td&gt;
&lt;td&gt;1.78 ± 0.03 s&lt;/td&gt;
&lt;td&gt;311 MB&lt;/td&gt;
&lt;td&gt;5.2&lt;/td&gt;
&lt;td&gt;custom regex set&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;repomix&lt;/td&gt;
&lt;td&gt;35.97 ± 1.24 s&lt;/td&gt;
&lt;td&gt;878 MB&lt;/td&gt;
&lt;td&gt;1.1&lt;/td&gt;
&lt;td&gt;Secretlint, incl. Node startup&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  What I'll claim, and what I won't
&lt;/h3&gt;

&lt;p&gt;Against the tools people actually have installed, the gap is not subtle: &lt;strong&gt;5x faster than yek. 20x faster than repomix, with both tools scanning — and that repomix number already includes Node startup in both time and memory, and it's running Secretlint, a different ruleset than gnaw's gitleaks. All three caveats, same sentence, because a 20x headline with the fine print three scrolls away is how vendor benchmarks lie.&lt;/strong&gt; And no, repomix is not the 22-minute dinosaur from the old yek benchmark everyone still quotes. They shipped serious parallelization work. It's 35 seconds now, current version. Still 20x.&lt;/p&gt;

&lt;p&gt;Against repomix-rs, with both tools scanning: &lt;strong&gt;1.01 ± 0.08. A dead heat.&lt;/strong&gt; I'm not going to torture that into a win. Here's the full trade in one breath: gnaw ties repomix-rs on wall-clock, carries 166 MB more peak RSS, and is running the full gitleaks ruleset where repomix-rs runs a custom regex set. The memory is the price of the bigger ruleset. Whether thoroughness is worth 166 MB is your call; I've made mine.&lt;/p&gt;

&lt;p&gt;One more honest one. Look at yek's user time: 8.0 CPU-seconds. Now look at gnaw's: 7.4. Nearly the same total work. gnaw spreads it across roughly five of the eight cores while yek sits on one — the cpu× column shows 5.2, and that's what the wall-clock gap is made of. On a 2-core CI runner, expect much less of it. (Why 5.2 and not 8? Traversal is partly I/O-bound. Profiling where the other cores went is on the list.)&lt;/p&gt;

&lt;p&gt;Now the number I actually care about. gnaw with scanning off: 1.55s. With a full gitleaks pass over 26,706 files: 1.76s.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The scan costs about 200 milliseconds.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I picked gitleaks knowing it was the heavier, slower option, and braced for a real cost. The bill came to a blink. Whatever era "secret scanning is too slow to leave on by default" belonged to, it's over.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fork
&lt;/h2&gt;

&lt;p&gt;code2prompt got the big thing right: packing a repo is something you should be able to steer, not just trigger. It has a TUI. Its extraction speed hangs with the native tools. That's why I forked it instead of starting from zero.&lt;/p&gt;

&lt;p&gt;What I changed:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live token counts.&lt;/strong&gt; The reason gnaw exists. You browse the tree, toggle files and directories in and out, and the token total updates as you go. Deselect &lt;code&gt;test/&lt;/code&gt;, watch tokens fall off the bill. A context window is a budget. The tool should show you spending it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx6ng5zl2hffkvsx9w5k1.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx6ng5zl2hffkvsx9w5k1.gif" alt=" " width="760" height="891"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Search that behaves the way I kept expecting it to.&lt;/strong&gt; Not a bug report, a taste difference. code2prompt's search does what it does; my fingers disagreed with it several times a day, so gnaw's does what my fingers expect. I'll write up the specific UX splits in a follow-up, because they're small, concrete, and the fun kind of arguable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;gitleaks as the default scanner.&lt;/strong&gt; The thorough option. I assumed I was trading speed for it. See above: 200ms. Easiest trade I've made this year. I'm building a planted-secrets corpus to measure recall and false positives against the lighter scanners properly. Until that's published, "more thorough" is my working belief, not a measured verdict.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A parallel extraction engine.&lt;/strong&gt; cpu× 5.2. The table already made this argument.&lt;/p&gt;

&lt;p&gt;The pure taste changes stay in the fork. Anything that turns out to be an objective fix goes upstream, where it belongs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gnaw is not
&lt;/h2&gt;

&lt;p&gt;An agent. If Claude Code is already picking good context for your task, close this tab, you're fine. gnaw is for when you want to be the retrieval step: deterministic, inspectable, scanned, and done in under two seconds instead of twenty minutes of clicking through GitHub copying files like it's 2023.&lt;/p&gt;

&lt;p&gt;It's also not done. Next on the roadmap: an &lt;strong&gt;MCP server&lt;/strong&gt;, so agents can call gnaw as a token-budgeted, secret-scanned context extractor instead of this being a manual-only tool. That's the version that makes sense in 2026, and it's the part I'm most looking forward to building.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check my work
&lt;/h2&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/gitbadger-clan/gnaw" rel="noopener noreferrer"&gt;gnaw&lt;/a&gt;. Quickstart in the README, benchmark script and Dockerfile next to it. &lt;code&gt;docker run&lt;/code&gt; it, and if your numbers disagree with mine, open an issue. I once misread my own benchmark by 5x; I have no standing to be precious about corrections.&lt;/p&gt;

&lt;p&gt;If it's useful, a star helps the next person find it.&lt;/p&gt;




</description>
      <category>ai</category>
      <category>rust</category>
      <category>tooling</category>
      <category>llm</category>
    </item>
    <item>
      <title>Rusty puppets, Websockets and Voyeurism (part II): Driving Chromium in Docker with a Window</title>
      <dc:creator>Artur Daschevici</dc:creator>
      <pubDate>Wed, 19 Nov 2025 09:05:00 +0000</pubDate>
      <link>https://dev.to/adaschevici/rusty-puppets-websockets-and-voyeurism-part-ii-driving-chromium-in-docker-with-a-window-30li</link>
      <guid>https://dev.to/adaschevici/rusty-puppets-websockets-and-voyeurism-part-ii-driving-chromium-in-docker-with-a-window-30li</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;You swapped Chrome → Chromium for better arm64 support, strapped on VNC + noVNC to watch the chaos, made Alpine optional to chase size gains, and pimped a Makefile so everything feels like a dead man's switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Backstory
&lt;/h2&gt;

&lt;p&gt;In some of my previous experiments I have used browser automation with Chrome to extract info from pages, usually via a wrapper around CDP. CDP has a pretty neat and quite huge API for driving Chrome and Chromium based browsers. Instead of using an existing wrapper, I built my own in Rust with &lt;code&gt;tungstenite&lt;/code&gt; to communicate over WebSocket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why?
&lt;/h2&gt;

&lt;p&gt;Ideally what I want is a server style primitive component that is able to speak CDP over a websocket without having to babysit a local browser instance. Normally the way you run most of the chrome automations is by running the process and if you want some transparency into what is happening you flip &lt;code&gt;--headless&lt;/code&gt; flag to false, but this is not what you want, the automation is outside the docker container, the container only holds the browser with the remote debugging port exposed. Something else that tripped me up was that there were no Chrome repos with arm64 builds that i could run on my M1 mac, so I switched to Chromium that supports arm64 better.&lt;/p&gt;

&lt;p&gt;Yes, "headless" is efficient; no, I don't trust it until I can see it wiggle, that is why an extra feature that felt right was having VNC enabled on one of the container variants.&lt;/p&gt;

&lt;h2&gt;
  
  
  What?
&lt;/h2&gt;

&lt;p&gt;Components (lego brick style)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chromium (not Chrome) – better support for arm64 builds, CDP at 9222.&lt;/li&gt;
&lt;li&gt;Socat - port forwarding for CDP, since binding to 0.0.0.0 did not work well with Chromium.&lt;/li&gt;
&lt;li&gt;Xvfb + lightweight WM (fluxbox) – fake display for the VNC stream.&lt;/li&gt;
&lt;li&gt;VNC server (x11vnc) – the eyeballs.&lt;/li&gt;
&lt;li&gt;noVNC + websockify – view it in the browser at :6080.&lt;/li&gt;
&lt;li&gt;supervisord – herd cats (multiple daemons).&lt;/li&gt;
&lt;li&gt;Alpine (optional) – minimal base; trade-offs: fonts, glibc shims, weird edges.&lt;/li&gt;
&lt;li&gt;Rust client – talks to &lt;a href="http://container:9222/json" rel="noopener noreferrer"&gt;http://container:9222/json&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+------------------ Docker Container --------------------+
|                                                        |
|  [Xvfb] --- [WM] --- [VNC Server] --- [noVNC]          |
|                         ^             (HTTP 8080)      |
|                         |                              |
|                    screen:0                            |
|                                                        |
|  [Chromium --headless=new --remote-debugging-port=9222]|
|                                         (WS:9222)      |
|                                ^                       |
|                                |                       |
|                            [Socat proxy]               |
+--------------------------------------------------------+

 Outside:
   Rust (chromiumoxide/others) --&amp;gt; http://host:&amp;lt;exposed-socat-port&amp;gt;
   Human -&amp;gt; http://host:6080 (noVNC)

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How?
&lt;/h2&gt;

&lt;p&gt;I.  Image strategy (two by two):&lt;/p&gt;

&lt;p&gt;Essentially what I wanted was efficiency at the container level and at the browser level. I applied that in practice by using &lt;code&gt;alpine&lt;/code&gt; for smaller image size and running headless for better runtime efficiency. However running blind is kinda hard to introspect so I added two additional images with VNC support and those came with both &lt;code&gt;alpine&lt;/code&gt; and &lt;code&gt;debian&lt;/code&gt; flavors.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Debian/Ubuntu base: fewer papercuts, bigger image, fastest to "it works."&lt;/li&gt;
&lt;li&gt;Alpine base: smallest, but bring your own fonts, codecs, and glibc cuddles.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Pro tip: start Debian for DX, ship Alpine once you tame fonts/codecs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;II.  Dockerfile (there is a common base between &lt;code&gt;debian&lt;/code&gt; and &lt;code&gt;alpine&lt;/code&gt;)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;#....&lt;/span&gt;
&lt;span class="c"&gt;# Default ports&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; CHROME_PORT=9222&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; SOCAT_PORT=9224&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; VNC_PORT=5900&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NOVNC_PORT=6080&lt;/span&gt;

&lt;span class="c"&gt;#....packages common to both debian and alpine&lt;/span&gt;
  vim \ # want to edit stuff?
  chromium \ # duh
  socat \ # port forwarding for the 9222 remote debugging port
  curl \ # healthcheck and manual testing
  net-tools \ # netstat useful for listing open ports
  iproute2 \ # ss useful for listing open ports
  ca-certificates \
  procps \


#.... Install VNC and GUI components
  supervisor \
  xvfb \
  x11vnc \
  websockify \
  fluxbox \
  git \

#....

# Install noVNC from source (most reliable method)
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /opt &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;  git clone &lt;span class="nt"&gt;--depth&lt;/span&gt; 1 https://github.com/novnc/noVNC.git &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="nb"&gt;cd &lt;/span&gt;noVNC &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; vnc.html index.html

&lt;span class="c"&gt;#....&lt;/span&gt;

&lt;span class="c"&gt;# Create necessary directories with proper permissions&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /home/chrome/data &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /var/log/supervisor &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; chrome:chrome /home/chrome &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; chrome:chrome /var/log/supervisor


&lt;span class="c"&gt;# Copy startup script&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; STEALTH=basic&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; ${STEALTH}.sh /usr/local/bin/start-chrome.sh&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /usr/local/bin/start-chrome.sh &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="nb"&gt;chown &lt;/span&gt;chrome:chrome /usr/local/bin/start-chrome.sh

&lt;span class="c"&gt;#....&lt;/span&gt;


&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; ${SOCAT_PORT} ${VNC_PORT} ${NOVNC_PORT}&lt;/span&gt;

&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["supervisord", "-n", "-c", "/etc/supervisor/conf.d/supervisord.conf"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;III. Dockerfile (lightweight Debian to get a more frictionless experience):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; debian:bookworm-slim&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; DEBIAN_FRONTEND=noninteractive&lt;/span&gt;

&lt;span class="c"&gt;# Install base dependencies&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="c"&gt;#....&lt;/span&gt;
  fonts-liberation \
  fonts-noto-color-emoji \
  fonts-roboto \
  fonts-noto \
  libasound2 \
  libatk-bridge2.0-0 \
  libatk1.0-0 \
  libatspi2.0-0 \
  libcups2 \
  libdbus-1-3 \
  libdrm2 \
  libgbm1 \
  libgtk-3-0 \
  libnspr4 \
  libnss3 \
  libwayland-client0 \
  libxcomposite1 \
  libxdamage1 \
  libxfixes3 \
  libxkbcommon0 \
  libxrandr2 \
  xdg-utils \
  &amp;amp;&amp;amp; rm -rf /var/lib/apt/lists/*

# Install VNC and GUI components
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;  gnupg &lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="c"&gt;#....&lt;/span&gt;
  python3 \
  python3-numpy \
  &amp;amp;&amp;amp; rm -rf /var/lib/apt/lists/*

#....

# Create a non-root user
&lt;span class="k"&gt;RUN &lt;/span&gt;useradd &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /bin/bash chrome

&lt;span class="c"&gt;#....&lt;/span&gt;

&lt;span class="c"&gt;# Copy supervisord config&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; supervisord.conf.debian /etc/supervisor/conf.d/supervisord.conf&lt;/span&gt;

&lt;span class="c"&gt;#....&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;IV. The &lt;code&gt;alpine&lt;/code&gt; version, similar but not quite the same, some commands are different and some of the package names and dependencies differ too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; alpine:3.19&lt;/span&gt;

&lt;span class="c"&gt;#....&lt;/span&gt;

&lt;span class="c"&gt;# Install base dependencies and Chromium&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apk add &lt;span class="nt"&gt;--no-cache&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="c"&gt;# Core utilities&lt;/span&gt;
  bash \ # using some custom scripts to launch the browser
  &lt;span class="c"&gt;# ....&lt;/span&gt;
  xdpyinfo \ # x11 utilities that are not included by default
  xauth \
  xprop \
  xwininfo \
  # Fonts
  font-liberation \
  font-noto \
  font-noto-emoji \
  font-noto-cjk \
  # Chromium dependencies
  libstdc++ \
  harfbuzz \
  nss \
  freetype \
  ttf-freefont \
  wqy-zenhei \
  # Audio/Video libraries
  alsa-lib \
  at-spi2-core \
  cups-libs \
  dbus-libs \
  libdrm \
  mesa-gbm \
  libxcomposite \
  libxdamage \
  libxfixes \
  libxkbcommon \
  libxrandr \
  wayland-libs-client \
  # X11 libraries
  libx11 \
  libxext \
  libxrender \
  libxtst \
  libxi

&lt;span class="c"&gt;# Install VNC and GUI components&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apk add &lt;span class="nt"&gt;--no-cache&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="c"&gt;#....&lt;/span&gt;
  python3 \
  py3-numpy \
  py3-pip

&lt;span class="c"&gt;#....&lt;/span&gt;

&lt;span class="c"&gt;# Create a non-root user (Alpine uses adduser instead of useradd)&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;adduser &lt;span class="nt"&gt;-D&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /bin/sh chrome

&lt;span class="c"&gt;#....&lt;/span&gt;

&lt;span class="c"&gt;# Copy supervisord config&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; supervisord.conf.alpine /etc/supervisor/conf.d/supervisord.conf&lt;/span&gt;

&lt;span class="c"&gt;#....&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;V. In order to manage the multiple containers &lt;code&gt;k8s&lt;/code&gt; would be overkill so I used &lt;code&gt;docker-compose&lt;/code&gt; instead. I like it for simple experiments when I have to do quick and incremental iteration for testing my containers. There is less of a need for cleaning up things after and less of a chance to make mistakes with &lt;code&gt;docker run&lt;/code&gt; commands.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chrome-cdp&lt;/span&gt;

&lt;span class="na"&gt;x-chrome-common&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nl"&gt;&amp;amp;chrome-common&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chrome-cdp:${DISTRO:-debian}-${MODE:-headless}-stealth-${STEALTH:-basic}&lt;/span&gt;
  &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chrome-${DISTRO:-debian}-${MODE:-headless}-stealth-${STEALTH:-basic}&lt;/span&gt;
  &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;cdpnet&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;shm_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${HEADLESS_SHM_SIZE}"&lt;/span&gt;
  &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
  &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;curl"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-fsS"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://0.0.0.0:${SOCAT_PORT}/json/version"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
    &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
    &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15s&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;chrome-headless&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;*chrome-common&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./headless&lt;/span&gt;
      &lt;span class="na"&gt;dockerfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Dockerfile.${DISTRO:-debian}&lt;/span&gt;
      &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;STEALTH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${STEALTH:-basic}&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${CDP_HOST_PORT}:${SOCAT_PORT}"&lt;/span&gt;

  &lt;span class="na"&gt;chrome-gui&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;*chrome-common&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./gui&lt;/span&gt;
      &lt;span class="na"&gt;dockerfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Dockerfile.${DISTRO:-debian}&lt;/span&gt;
      &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;STEALTH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${STEALTH:-basic}&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${CDP_HOST_PORT}:${SOCAT_PORT}"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${VNC_PORT}:5900"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${NOVNC_PORT}:6080"&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cdpnet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bridge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;as you can see the &lt;code&gt;docker-compose&lt;/code&gt; file is slightly streamlined by using YAML anchors and aliases to avoid repetition between the two services. But this is not the most interesting DX improvements I added.&lt;/p&gt;

&lt;p&gt;VI. The pimped out &lt;code&gt;Makefile&lt;/code&gt;. The big deal with this one is that I got everything working almost like an extension of make, where previously I would just be creating targets to wrap &lt;code&gt;docker-compose&lt;/code&gt; now I added a bunch of more advanced params to allow parametrization of each target instead of doing different names for each variant.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="nv"&gt;SHELL&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; /bin/bash
&lt;span class="nv"&gt;ENV_FILE&lt;/span&gt; &lt;span class="o"&gt;?=&lt;/span&gt; .env
&lt;span class="nv"&gt;STEALTH&lt;/span&gt; &lt;span class="o"&gt;?=&lt;/span&gt; basic
&lt;span class="nv"&gt;MODE&lt;/span&gt; &lt;span class="o"&gt;?=&lt;/span&gt; headless
&lt;span class="nv"&gt;DISTRO&lt;/span&gt; &lt;span class="o"&gt;?=&lt;/span&gt; debian

&lt;span class="c"&gt;# Container name (adjust to match your docker-compose service name)
&lt;/span&gt;&lt;span class="nv"&gt;CONTAINER_NAME&lt;/span&gt; &lt;span class="o"&gt;?=&lt;/span&gt; chromium
&lt;span class="nv"&gt;COMPOSE_FILE&lt;/span&gt; &lt;span class="o"&gt;?=&lt;/span&gt; docker-compose.yml

&lt;span class="nl"&gt;.PHONY&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;health list-containers ps stats stats-all stats-live top &lt;/span&gt;\
&lt;span class="nf"&gt;   ports ports-all ports-detailed logs logs-chrome logs-chrome-live &lt;/span&gt;\
&lt;span class="nf"&gt;   shell rebuild up down chrome-version chrome-tabs chrome-health verify-chrome-flags &lt;/span&gt;\
&lt;span class="nf"&gt;   restart-all stop-all wsurl&lt;/span&gt;

&lt;span class="c"&gt;# ============================================
# Configuration
# ============================================
&lt;/span&gt;&lt;span class="nv"&gt;CHROME_IMAGE_PREFIX&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; chrome-cdp
&lt;span class="nv"&gt;CHROME_IMAGES&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="p"&gt;$(&lt;/span&gt;CHROME_IMAGE_PREFIX&lt;span class="p"&gt;)&lt;/span&gt;:debian-headless-stealth-basic &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="p"&gt;$(&lt;/span&gt;CHROME_IMAGE_PREFIX&lt;span class="p"&gt;)&lt;/span&gt;:debian-headless-stealth-advanced &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="p"&gt;$(&lt;/span&gt;CHROME_IMAGE_PREFIX&lt;span class="p"&gt;)&lt;/span&gt;:debian-gui-stealth-basic &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="p"&gt;$(&lt;/span&gt;CHROME_IMAGE_PREFIX&lt;span class="p"&gt;)&lt;/span&gt;:debian-gui-stealth-advanced &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="p"&gt;$(&lt;/span&gt;CHROME_IMAGE_PREFIX&lt;span class="p"&gt;)&lt;/span&gt;:alpine-headless-stealth-basic &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="p"&gt;$(&lt;/span&gt;CHROME_IMAGE_PREFIX&lt;span class="p"&gt;)&lt;/span&gt;:alpine-headless-stealth-advanced &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="p"&gt;$(&lt;/span&gt;CHROME_IMAGE_PREFIX&lt;span class="p"&gt;)&lt;/span&gt;:alpine-gui-stealth-basic &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="p"&gt;$(&lt;/span&gt;CHROME_IMAGE_PREFIX&lt;span class="p"&gt;)&lt;/span&gt;:alpine-gui-stealth-advanced

&lt;span class="c"&gt;# Build docker filter arguments
&lt;/span&gt;&lt;span class="nv"&gt;DOCKER_FILTERS&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;foreach img,&lt;span class="p"&gt;$(&lt;/span&gt;CHROME_IMAGES&lt;span class="p"&gt;)&lt;/span&gt;,--filter &lt;span class="s2"&gt;"ancestor=&lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;&lt;span class="s2"&gt;img&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="c"&gt;# Command to get container
&lt;/span&gt;&lt;span class="nv"&gt;GET_CONTAINER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; docker ps &lt;span class="p"&gt;$(&lt;/span&gt;DOCKER_FILTERS&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;

&lt;span class="c"&gt;# Command to get all containers
&lt;/span&gt;&lt;span class="nv"&gt;GET_ALL_CONTAINERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; docker ps &lt;span class="p"&gt;$(&lt;/span&gt;DOCKER_FILTERS&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt;

&lt;span class="c"&gt;# ============================================
# Helper Functions
# ============================================
# Check if container exists and set CONTAINER variable
&lt;/span&gt;&lt;span class="k"&gt;define&lt;/span&gt; &lt;span class="nv"&gt;require_container&lt;/span&gt;
 &lt;span class="nl"&gt;$(eval CONTAINER &lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nf"&gt;= $(shell $(GET_CONTAINER)))&lt;/span&gt;
 &lt;span class="err"&gt;@if&lt;/span&gt; &lt;span class="err"&gt;[&lt;/span&gt; &lt;span class="err"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"$(CONTAINER)"&lt;/span&gt; &lt;span class="err"&gt;];&lt;/span&gt; &lt;span class="err"&gt;then&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="err"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"❌ No $(CHROME_IMAGE_PREFIX) container running"&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="nl"&gt;echo "Available images&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;$(CHROME_IMAGES)"; &lt;/span&gt;\
&lt;span class="nf"&gt;  exit 1; &lt;/span&gt;\
&lt;span class="nf"&gt; fi&lt;/span&gt;
 &lt;span class="nl"&gt;@echo "✓ Using container&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;$(CONTAINER)"&lt;/span&gt;
&lt;span class="k"&gt;endef&lt;/span&gt;

&lt;span class="c"&gt;# ============================================
# Targets
# ============================================
&lt;/span&gt;&lt;span class="nl"&gt;help&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="err"&gt;@echo&lt;/span&gt; &lt;span class="s2"&gt;"Chrome Container Management"&lt;/span&gt;
 &lt;span class="err"&gt;@echo&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;
 &lt;span class="nl"&gt;@echo "Available targets&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nf"&gt;"&lt;/span&gt;
 &lt;span class="err"&gt;@echo&lt;/span&gt; &lt;span class="s2"&gt;"  make list-containers    - List all running chrome containers"&lt;/span&gt;
 &lt;span class="err"&gt;@echo&lt;/span&gt; &lt;span class="s2"&gt;"  make verify-chrome-flags - Verify stealth flags in Chrome"&lt;/span&gt;
 &lt;span class="err"&gt;@echo&lt;/span&gt; &lt;span class="s2"&gt;"  make stats              - Show container stats"&lt;/span&gt;
 &lt;span class="err"&gt;@echo&lt;/span&gt; &lt;span class="s2"&gt;"  make stats-all          - Show stats for all chrome containers"&lt;/span&gt;
 &lt;span class="err"&gt;@echo&lt;/span&gt; &lt;span class="s2"&gt;"  make logs               - Show container logs"&lt;/span&gt;
 &lt;span class="c"&gt;# @echo "  make exec CMD=&amp;lt;cmd&amp;gt;     - Execute command in container"
&lt;/span&gt; &lt;span class="err"&gt;@echo&lt;/span&gt; &lt;span class="s2"&gt;"  make shell              - Open shell in container"&lt;/span&gt;

&lt;span class="nl"&gt;list-containers&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="nl"&gt;@echo "Running chrome-cdp containers&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nf"&gt;"&lt;/span&gt;
 &lt;span class="err"&gt;@docker&lt;/span&gt; &lt;span class="err"&gt;ps&lt;/span&gt; &lt;span class="err"&gt;$(DOCKER_FILTERS)&lt;/span&gt; &lt;span class="err"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"table {{.ID}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"&lt;/span&gt;

&lt;span class="nl"&gt;up&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="nl"&gt;@echo "Building and starting chromium container in $(MODE) mode with $(DISTRO) (stealth&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;$(STEALTH))"&lt;/span&gt;
 &lt;span class="nv"&gt;DISTRO&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;DISTRO&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;MODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;MODE&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;STEALTH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;STEALTH&lt;span class="p"&gt;)&lt;/span&gt; docker compose &lt;span class="nt"&gt;--env-file&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;ENV_FILE&lt;span class="p"&gt;)&lt;/span&gt; up chrome-&lt;span class="p"&gt;$(&lt;/span&gt;MODE&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;

&lt;span class="nl"&gt;down&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="err"&gt;docker&lt;/span&gt; &lt;span class="err"&gt;compose&lt;/span&gt; &lt;span class="err"&gt;--env-file&lt;/span&gt; &lt;span class="err"&gt;$(ENV_FILE)&lt;/span&gt; &lt;span class="err"&gt;down&lt;/span&gt;

&lt;span class="nl"&gt;logs&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="nf"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;call&lt;/span&gt; require_container&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="err"&gt;@docker&lt;/span&gt; &lt;span class="err"&gt;logs&lt;/span&gt; &lt;span class="err"&gt;-f&lt;/span&gt; &lt;span class="err"&gt;$(CONTAINER)&lt;/span&gt;

&lt;span class="nl"&gt;shell&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="nf"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;call&lt;/span&gt; require_container&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="err"&gt;@docker&lt;/span&gt; &lt;span class="err"&gt;exec&lt;/span&gt; &lt;span class="err"&gt;-it&lt;/span&gt; &lt;span class="err"&gt;$(CONTAINER)&lt;/span&gt; &lt;span class="err"&gt;bash&lt;/span&gt;

&lt;span class="nl"&gt;rebuild&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="nv"&gt;DISTRO&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;DISTRO&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;MODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;MODE&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;STEALTH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;STEALTH&lt;span class="p"&gt;)&lt;/span&gt; docker compose &lt;span class="nt"&gt;--env-file&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;ENV_FILE&lt;span class="p"&gt;)&lt;/span&gt; build &lt;span class="nt"&gt;--no-cache&lt;/span&gt; chrome-&lt;span class="p"&gt;$(&lt;/span&gt;MODE&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nl"&gt;ps&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="err"&gt;docker&lt;/span&gt; &lt;span class="err"&gt;compose&lt;/span&gt; &lt;span class="err"&gt;--env-file&lt;/span&gt; &lt;span class="err"&gt;$(ENV_FILE)&lt;/span&gt; &lt;span class="err"&gt;ps&lt;/span&gt;

&lt;span class="nl"&gt;health&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="nl"&gt;@echo "Headless&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nf"&gt;" &amp;amp;&amp;amp; curl -fsS http://127.0.0.1:$$(grep ^CDP_HOST_PORT $(ENV_FILE) | cut -d= -f2)/json/version | jq -r .Browser || true&lt;/span&gt;
 &lt;span class="nl"&gt;@echo "GUI&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nf"&gt;" &amp;amp;&amp;amp; curl -fsS http://127.0.0.1:$$(grep ^CDP_PORT_GUI $(ENV_FILE) | cut -d= -f2)/json/version | jq -r .Browser || true&lt;/span&gt;

&lt;span class="c"&gt;# Quick helper to print a page websocketDebuggerUrl (requires jq)
&lt;/span&gt;&lt;span class="nl"&gt;wsurl&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="nl"&gt;@curl -s "http&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nf"&gt;//127.0.0.1:$$(grep ^CDP_PORT_HEADLESS $(ENV_FILE) | cut -d= -f2)/json/new?about:blank" | jq -r .webSocketDebuggerUrl&lt;/span&gt;
 &lt;span class="nl"&gt;@curl -s "http&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nf"&gt;//127.0.0.1:$$(grep ^CDP_PORT_GUI $(ENV_FILE) | cut -d= -f2)/json/new?about:blank" | jq -r .webSocketDebuggerUrl&lt;/span&gt;


&lt;span class="c"&gt;# ============================================
# Chrome-specific commands
# ============================================
&lt;/span&gt;&lt;span class="nl"&gt;chrome-version&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="nf"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;call&lt;/span&gt; require_container&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="err"&gt;@docker&lt;/span&gt; &lt;span class="err"&gt;exec&lt;/span&gt; &lt;span class="err"&gt;$(CONTAINER)&lt;/span&gt; &lt;span class="err"&gt;chromium&lt;/span&gt; &lt;span class="err"&gt;--version&lt;/span&gt;

&lt;span class="nl"&gt;chrome-tabs&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="nf"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;call&lt;/span&gt; require_container&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nv"&gt;CHROME_PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$$(&lt;/span&gt;docker port &lt;span class="p"&gt;$(&lt;/span&gt;CONTAINER&lt;span class="p"&gt;)&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'9222|9223|9224'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;' -&amp;gt; '&lt;/span&gt; &lt;span class="s1"&gt;'{print $$2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;CHROME_PORT"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"❌ Chrome DevTools port not found"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="k"&gt;fi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Fetching tabs from http://&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;CHROME_PORT/json"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"http://&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;CHROME_PORT/json"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] | "\(.id): \(.title) - \(.url)"'&lt;/span&gt;

&lt;span class="nl"&gt;chrome-health&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="nf"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;call&lt;/span&gt; require_container&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nv"&gt;CHROME_PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$$(&lt;/span&gt;docker port &lt;span class="p"&gt;$(&lt;/span&gt;CONTAINER&lt;span class="p"&gt;)&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'9222|9223|9224'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;' -&amp;gt; '&lt;/span&gt; &lt;span class="s1"&gt;'{print $$2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;CHROME_PORT"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"❌ Chrome DevTools port not found"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="k"&gt;fi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== Chrome DevTools Health Check ==="&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Endpoint: http://&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;CHROME_PORT"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="k"&gt;if &lt;/span&gt;curl &lt;span class="nt"&gt;-sf&lt;/span&gt; &lt;span class="s2"&gt;"http://&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;CHROME_PORT/json/version"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"✅ Chrome DevTools is responding"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"http://&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;CHROME_PORT/json/version"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'"Browser: \(.Browser)\nProtocol Version: \(."Protocol-Version")\nUser Agent: \(."User-Agent")"'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"❌ Chrome not responding"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="nl"&gt;verify-chrome-flags&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="nf"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;call&lt;/span&gt; require_container&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="err"&gt;@echo&lt;/span&gt; &lt;span class="s2"&gt;"Verifying Chrome flags in container $(CONTAINER)..."&lt;/span&gt;
 &lt;span class="err"&gt;@docker&lt;/span&gt; &lt;span class="err"&gt;exec&lt;/span&gt; &lt;span class="err"&gt;$(CONTAINER)&lt;/span&gt; &lt;span class="err"&gt;sh&lt;/span&gt; &lt;span class="err"&gt;-c&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"cat /proc/\$$(pgrep -o chromium)/cmdline | tr '\0' '\n' | grep -E 'disable-blink-features|user-agent'"&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="err"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="err"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"✅ Stealth flags detected"&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="err"&gt;||&lt;/span&gt; &lt;span class="err"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"❌ No stealth flags"&lt;/span&gt;

&lt;span class="nl"&gt;stats&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="nf"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;call&lt;/span&gt; require_container&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="nl"&gt;@echo "Container stats&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="nf"&gt;"&lt;/span&gt;
 &lt;span class="err"&gt;@docker&lt;/span&gt; &lt;span class="err"&gt;stats&lt;/span&gt; &lt;span class="err"&gt;--no-stream&lt;/span&gt; &lt;span class="err"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}"&lt;/span&gt; &lt;span class="err"&gt;$(CONTAINER)&lt;/span&gt;

&lt;span class="nl"&gt;stats-all&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nv"&gt;CONTAINERS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$$($(&lt;/span&gt;GET_ALL_CONTAINERS&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;CONTAINERS"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"❌ No &lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;&lt;span class="s2"&gt;CHROME_IMAGE_PREFIX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; containers running"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="k"&gt;fi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Stats for all chrome containers:"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 docker stats &lt;span class="nt"&gt;--no-stream&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s2"&gt;"table {{.Container}}&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;{{.Image}}&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;{{.CPUPerc}}&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;{{.MemUsage}}&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;{{.MemPerc}}"&lt;/span&gt; &lt;span class="nv"&gt;$$&lt;/span&gt;CONTAINERS

&lt;span class="nl"&gt;stats-live&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nv"&gt;CONTAINERS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$$($(&lt;/span&gt;GET_ALL_CONTAINERS&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;CONTAINERS"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"❌ No &lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;&lt;span class="s2"&gt;CHROME_IMAGE_PREFIX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; containers running"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="k"&gt;fi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 docker stats &lt;span class="nv"&gt;$$&lt;/span&gt;CONTAINERS

&lt;span class="nl"&gt;top&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nv"&gt;CONTAINER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$$($(&lt;/span&gt;GET_CONTAINER&lt;span class="p"&gt;))&lt;/span&gt;
 &lt;span class="err"&gt;@echo&lt;/span&gt; &lt;span class="s2"&gt;"=== Top Processes in Container ==="&lt;/span&gt;
 &lt;span class="err"&gt;@docker&lt;/span&gt; &lt;span class="err"&gt;top&lt;/span&gt; &lt;span class="err"&gt;$$(docker-compose&lt;/span&gt; &lt;span class="err"&gt;-f&lt;/span&gt; &lt;span class="err"&gt;$(COMPOSE_FILE)&lt;/span&gt; &lt;span class="err"&gt;ps&lt;/span&gt; &lt;span class="err"&gt;-q&lt;/span&gt; &lt;span class="err"&gt;$$CONTAINER)&lt;/span&gt;

&lt;span class="c"&gt;# ============================================
# Bulk operations
# ============================================
&lt;/span&gt;&lt;span class="nl"&gt;restart-all&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nv"&gt;CONTAINERS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$$($(&lt;/span&gt;GET_ALL_CONTAINERS&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;CONTAINERS"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"❌ No containers to restart"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="k"&gt;fi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Restarting all chrome containers..."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 docker restart &lt;span class="nv"&gt;$$&lt;/span&gt;CONTAINERS

&lt;span class="nl"&gt;stop-all&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nv"&gt;CONTAINERS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$$($(&lt;/span&gt;GET_ALL_CONTAINERS&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;CONTAINERS"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"❌ No containers to stop"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="k"&gt;fi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Stopping all chrome containers..."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 docker stop &lt;span class="nv"&gt;$$&lt;/span&gt;CONTAINERS

&lt;span class="c"&gt;# Show port mappings for a single container
&lt;/span&gt;&lt;span class="nl"&gt;ports&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="nf"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;call&lt;/span&gt; require_container&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="err"&gt;@echo&lt;/span&gt; &lt;span class="s2"&gt;"=== Port Mappings for Container $(CONTAINER) ==="&lt;/span&gt;
 &lt;span class="err"&gt;@echo&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;
 &lt;span class="err"&gt;@docker&lt;/span&gt; &lt;span class="err"&gt;port&lt;/span&gt; &lt;span class="err"&gt;$(CONTAINER)&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="err"&gt;awk&lt;/span&gt; &lt;span class="err"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;' -&amp;gt; '&lt;/span&gt; &lt;span class="s1"&gt;'{print $$1 "\t→\t" $$2}'&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="err"&gt;column&lt;/span&gt; &lt;span class="err"&gt;-t&lt;/span&gt; &lt;span class="err"&gt;-s&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="s1"&gt;'\t'&lt;/span&gt;

&lt;span class="c"&gt;# Show port mappings for all chrome containers in a table
&lt;/span&gt;&lt;span class="nl"&gt;ports-all&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nv"&gt;CONTAINERS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$$($(&lt;/span&gt;GET_ALL_CONTAINERS&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;CONTAINERS"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"❌ No &lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;&lt;span class="s2"&gt;CHROME_IMAGE_PREFIX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; containers running"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="k"&gt;fi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== Port Mappings for All Chrome Containers ==="&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"%-15s %-40s %-20s %-20s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"CONTAINER ID"&lt;/span&gt; &lt;span class="s2"&gt;"IMAGE"&lt;/span&gt; &lt;span class="s2"&gt;"CONTAINER PORT"&lt;/span&gt; &lt;span class="s2"&gt;"HOST BINDING"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"%-15s %-40s %-20s %-20s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"---------------"&lt;/span&gt; &lt;span class="s2"&gt;"----------------------------------------"&lt;/span&gt; &lt;span class="s2"&gt;"--------------------"&lt;/span&gt; &lt;span class="s2"&gt;"--------------------"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="k"&gt;for &lt;/span&gt;container &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$$&lt;/span&gt;CONTAINERS&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;IMAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$$(&lt;/span&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{{.Config.Image&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;}'&lt;/span&gt; &lt;span class="nv"&gt;$$&lt;/span&gt;container&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;SHORT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$$&lt;/span&gt;container | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-c1-12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  docker port &lt;span class="nv"&gt;$$&lt;/span&gt;container | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; line&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
   &lt;span class="nv"&gt;CONTAINER_PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;line"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;' -&amp;gt; '&lt;/span&gt; &lt;span class="s1"&gt;'{print $$1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
   &lt;span class="nv"&gt;HOST_BINDING&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;line"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;' -&amp;gt; '&lt;/span&gt; &lt;span class="s1"&gt;'{print $$2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
   &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"%-15s %-40s %-20s %-20s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;SHORT_ID"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;IMAGE"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;CONTAINER_PORT"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;HOST_BINDING"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="k"&gt;done&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# Detailed port information with service names
&lt;/span&gt;&lt;span class="nl"&gt;ports-detailed&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="nf"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;call&lt;/span&gt; require_container&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="err"&gt;@echo&lt;/span&gt; &lt;span class="s2"&gt;"=== Detailed Port Information for Container $(CONTAINER) ==="&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
 &lt;span class="err"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="err"&gt;;&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
 &lt;span class="nv"&gt;IMAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$$(&lt;/span&gt;docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{{.Config.Image&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;}'&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;CONTAINER&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Container: &lt;/span&gt;&lt;span class="p"&gt;$(&lt;/span&gt;&lt;span class="s2"&gt;CONTAINER&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="se"&gt;\&lt;/span&gt;
 &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Image: &lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;IMAGE"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"%-35s %-20s %-25s %-15s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"SERVICE"&lt;/span&gt; &lt;span class="s2"&gt;"CONTAINER PORT"&lt;/span&gt; &lt;span class="s2"&gt;"HOST BINDING"&lt;/span&gt; &lt;span class="s2"&gt;"PROTOCOL"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"%-35s %-20s %-25s %-15s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"----------------------------------"&lt;/span&gt; &lt;span class="s2"&gt;"--------------------"&lt;/span&gt; &lt;span class="s2"&gt;"-------------------------"&lt;/span&gt; &lt;span class="s2"&gt;"---------------"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 docker port &lt;span class="p"&gt;$(&lt;/span&gt;CONTAINER&lt;span class="p"&gt;)&lt;/span&gt; | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; line&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;CONTAINER_PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;line"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;' -&amp;gt; '&lt;/span&gt; &lt;span class="s1"&gt;'{print $$1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;PROTOCOL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;line"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;' -&amp;gt; '&lt;/span&gt; &lt;span class="s1"&gt;'{print $$1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;HOST_BINDING&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;$$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;line"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;' -&amp;gt; '&lt;/span&gt; &lt;span class="s1"&gt;'{print $$2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="nv"&gt;$$&lt;/span&gt;CONTAINER_PORT &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
   9222|9223|9224&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;SERVICE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Chrome DevTools Socat Proxy"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
   5900&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;SERVICE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"VNC Server"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
   6080&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;SERVICE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"noVNC Web"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
   &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;SERVICE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Unknown"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="k"&gt;esac&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"%-35s %-20s %-25s %-15s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;SERVICE"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;CONTAINER_PORT"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;HOST_BINDING"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;PROTOCOL"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
 &lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# View Chrome startup script logs
&lt;/span&gt;&lt;span class="nl"&gt;logs-chrome&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="nf"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;call&lt;/span&gt; require_container&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="err"&gt;@echo&lt;/span&gt; &lt;span class="s2"&gt;"=== Supervisor Chrome Program Logs ==="&lt;/span&gt;
 &lt;span class="err"&gt;@docker&lt;/span&gt; &lt;span class="err"&gt;exec&lt;/span&gt; &lt;span class="err"&gt;$(CONTAINER)&lt;/span&gt; &lt;span class="err"&gt;cat&lt;/span&gt; &lt;span class="err"&gt;/var/log/supervisor/chrome.log&lt;/span&gt; &lt;span class="err"&gt;2&amp;gt;/dev/null&lt;/span&gt; &lt;span class="err"&gt;||&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="err"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Chrome log file not found"&lt;/span&gt;

&lt;span class="c"&gt;# Live tail of Chrome logs
&lt;/span&gt;&lt;span class="nl"&gt;logs-chrome-live&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
 &lt;span class="nf"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;call&lt;/span&gt; require_container&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="err"&gt;@echo&lt;/span&gt; &lt;span class="s2"&gt;"=== Live Chrome Logs (Ctrl+C to exit) ==="&lt;/span&gt;
 &lt;span class="err"&gt;@docker&lt;/span&gt; &lt;span class="err"&gt;logs&lt;/span&gt; &lt;span class="err"&gt;-f&lt;/span&gt; &lt;span class="err"&gt;$(CONTAINER)&lt;/span&gt; &lt;span class="err"&gt;2&amp;gt;&amp;amp;1&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="err"&gt;grep&lt;/span&gt; &lt;span class="err"&gt;--line-buffered&lt;/span&gt; &lt;span class="s2"&gt;"CHROMIUM\|chromium\|Chrome"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Perfomance &amp;amp; size notes
&lt;/h2&gt;

&lt;p&gt;Alpine and Debian size are roughly the same with the current layout, so whatever I am doing wrong, needs a bit more investigation. The list of installed packages can probably be trimmed a bit more, maybe I get some extra savings but I don't expect them to be noticeable at small scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security notes
&lt;/h2&gt;

&lt;p&gt;Haven't added any specific security protocols so don't run this in production without:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Gating with VPN or bind to 127.0.0.1.&lt;/li&gt;
&lt;li&gt;Add auth to noVNC/websockify or stick it behind a reverse proxy with auth.&lt;/li&gt;
&lt;li&gt;Run as non-root (done above), and keep --no-sandbox only inside containers you control.&lt;/li&gt;
&lt;li&gt;Pin package versions for reproducible builds.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  WTF moments (and fixes)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Had a bunch a pain with the &lt;code&gt;chromium&lt;/code&gt; remote debugging port as it would not bind to &lt;code&gt;0.0.0.0:2222&lt;/code&gt; on the container so I had to add &lt;code&gt;socat&lt;/code&gt; to forward the port. When trying to connect to the remote debugging port of chromium i was getting a connection reset by peer error. Turns out chromium only binds to localhost inside the container unless you use &lt;code&gt;socat&lt;/code&gt; to forward the port.&lt;/li&gt;
&lt;li&gt;When the browser was not launching inside the container, I thought I messed up, but, as it turns out, any kind of &lt;code&gt;--headless&lt;/code&gt; flags should probably be omitted when running with VNC, otherwise the browser will not show up in the VNC session.&lt;/li&gt;
&lt;li&gt;this is a hack and probably you can fix it in a better way, but I am lazy, so 2MB of overhead on alpine is me adding bash. I used bash to start chrome with the wrapper script, add the flags all that jazz.&lt;/li&gt;
&lt;li&gt;in my first attempt I tried to use chrome, but there were some silly issues with arm64 builds, so I switched to chromium which has better support for arm64.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My conclusion
&lt;/h2&gt;

&lt;p&gt;I will use this setup to automate via CDP. It decouples the browser from the language. I know that this is not ideal as a solo dev but hey it's fun. So I will give it a try.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>rust</category>
      <category>playwright</category>
    </item>
    <item>
      <title>How I optimized my blog images using Rust</title>
      <dc:creator>Artur Daschevici</dc:creator>
      <pubDate>Wed, 12 Feb 2025 10:15:00 +0000</pubDate>
      <link>https://dev.to/adaschevici/how-i-optimized-my-blog-images-using-rust-3008</link>
      <guid>https://dev.to/adaschevici/how-i-optimized-my-blog-images-using-rust-3008</guid>
      <description>&lt;h2&gt;
  
  
  Why?
&lt;/h2&gt;

&lt;p&gt;Ages ago I read a blog post about how images can be optimized and that gives visitors to your site a better experience as the site loads faster yadda, yadda. Why would anyone not want this?&lt;/p&gt;

&lt;p&gt;My blog is built with &lt;a href="https://www.getzola.org/" rel="noopener noreferrer"&gt;zola&lt;/a&gt; so I am a bit of a &lt;code&gt;rust&lt;/code&gt; fanboy and want to use &lt;code&gt;rust&lt;/code&gt; whenever it makes sense and my novice skills can handle it. Not going to lie, I am not a &lt;code&gt;rust&lt;/code&gt; expert and for me &lt;code&gt;ChatGPT&lt;/code&gt; is a useful tool as it most often than not points me in the right direction.&lt;/p&gt;

&lt;p&gt;But I digress, a few days ago I decided to resurrect my blog, I tried to write a bit more consistently last year and got a streak of a few articles going, was feeling pretty good about it, but then my daughter was born and I was thrown in the gauntlet of figuring things out as a first time dad and the blog was left to rot. Since I am a bit more web marketing savvy, I decided to add some SEO to my blog, maybe I get some more visitors to it and get a sense of how popular it is.&lt;/p&gt;

&lt;p&gt;I am trying to be polite with the people that land on my blog and not track them so I don't use cookies. I host my stuff on &lt;code&gt;cloudflare&lt;/code&gt; since that gives the best bang for my buck. In other words I want my blog to be performant and free to host.&lt;/p&gt;

&lt;p&gt;My blog uses some analytics that are available through &lt;a href="https://www.cloudflare.com/web-analytics/" rel="noopener noreferrer"&gt;&lt;code&gt;cloudflare&lt;/code&gt;&lt;/a&gt; but they are very respectful of user privacy in that they are &lt;code&gt;GDPR&lt;/code&gt; and &lt;code&gt;CCPA&lt;/code&gt; compliant. This saves me the hassle of having to add a cookie consent form that disrupts the user navigation experience. I both like and dislike the analytics from &lt;code&gt;cloudflare&lt;/code&gt; as the numbers I am seeing are a bit weird as I am only seeing a constant number.&lt;/p&gt;

&lt;p&gt;Since I learned a bit more about &lt;code&gt;SEO&lt;/code&gt; and about &lt;a href="https://search.google.com/search-console/about" rel="noopener noreferrer"&gt;&lt;code&gt;Google Search Console&lt;/code&gt;&lt;/a&gt; I decided to check my blog's performance and see what I can do to improve it. Submitted my sitemap and ran a performance check and even if performance was at 100/100 I saw that the images were not optimized.&lt;/p&gt;

&lt;p&gt;My OCD kicked in and I had to figure out a way to address it, especially since I remembered that I read &lt;a href="https://endler.dev/2020/perf" rel="noopener noreferrer"&gt;an article&lt;/a&gt; talking about this. I dug into it a bit and noticed he is using &lt;code&gt;ImageMagik&lt;/code&gt;, &lt;code&gt;cavif&lt;/code&gt; and &lt;code&gt;cwebp&lt;/code&gt; to optimize the images, I decided to go a different way, essentially almost reinventing the wheel. I built a &lt;code&gt;rust cli&lt;/code&gt; that converts bigger &lt;code&gt;png&lt;/code&gt; and &lt;code&gt;jpeg&lt;/code&gt; images to &lt;code&gt;webp&lt;/code&gt; or &lt;code&gt;cavif&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frcn92fx817ulkee4tp7z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frcn92fx817ulkee4tp7z.png" alt="Image optimization for SEO benefits" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How?
&lt;/h2&gt;

&lt;p&gt;The step by step process looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Change the shortcode for images to try and render the optimal image if supported by the browser&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;write the rust tool that traverses the directory tree and convert images to &lt;code&gt;webp&lt;/code&gt; or &lt;code&gt;avif&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;integrate the tool into the github action pipeline&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;perform caching on the github workflow to avoid spending too many github minutes on the actual conversion&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's in it for me?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;1. Storage Cost Savings&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AVIF can be ~70% smaller than PNG&lt;/strong&gt; while maintaining similar or better quality.&lt;/li&gt;
&lt;li&gt;If you're storing images on &lt;strong&gt;AWS S3, Google Cloud Storage, DigitalOcean Spaces, or another cloud provider&lt;/strong&gt;, reducing storage by &lt;strong&gt;70%&lt;/strong&gt; directly cuts storage costs by the same percentage.
&lt;strong&gt;Example:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;100GB of PNGs → ~30GB of AVIF&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;If storage costs &lt;strong&gt;$0.023 per GB (AWS S3 Standard)&lt;/strong&gt;:&lt;/li&gt;
&lt;li&gt;PNG: &lt;strong&gt;$2.30/month&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;AVIF: &lt;strong&gt;$0.69/month&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Savings: ~$1.61 per 100GB/month (~70%)&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;2. Egress Bandwidth Cost Savings&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Most cloud providers charge for outbound bandwidth (data transferred to users).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Smaller AVIF files mean lower bandwidth usage, leading to significant savings.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AVIF reduces bandwidth usage by ~70% compared to PNG.&lt;/strong&gt;
&lt;strong&gt;Example with AWS CloudFront:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data transfer cost (to the internet):&lt;/strong&gt; &lt;strong&gt;$0.085 per GB&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;If you serve 1TB of PNGs per month:&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PNG: 1TB → $85/month&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AVIF (70% smaller): 0.3TB → $25.50/month&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Savings: ~$59.50 per TB/month (~70%)&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;3. CDN Caching &amp;amp; Requests&lt;/strong&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Many e-commerce sites use a &lt;strong&gt;CDN (Cloudflare, CloudFront, Fastly, etc.)&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Smaller images:

&lt;ul&gt;
&lt;li&gt;Improve &lt;strong&gt;cache hit ratio&lt;/strong&gt; (more images fit in CDN cache).&lt;/li&gt;
&lt;li&gt;Reduce &lt;strong&gt;origin fetch requests&lt;/strong&gt;, further lowering egress costs.&lt;/li&gt;
&lt;li&gt;Speed up load times, improving user experience.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Total Cost Savings Estimate&lt;/strong&gt;
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Cost Factor&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;PNG&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;AVIF&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Savings&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Storage (100GB)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$2.30&lt;/td&gt;
&lt;td&gt;$0.69&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$1.61 (70%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Egress (1TB/month)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$85.00&lt;/td&gt;
&lt;td&gt;$25.50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$59.50 (70%)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total Savings per TB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$87.11/month&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Playbook
&lt;/h2&gt;

&lt;h4&gt;
  
  
  Step 1: Shortcode
&lt;/h4&gt;

&lt;p&gt;I avoided using javascript for this since &lt;code&gt;html&lt;/code&gt; already gives a mechanism to render an image with a fallback&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;picture&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;source&lt;/span&gt; &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"{{id}}.avif"&lt;/span&gt; &lt;span class="err"&gt;{%&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt; &lt;span class="na"&gt;alt&lt;/span&gt; &lt;span class="err"&gt;%}&lt;/span&gt;&lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"{{alt}}"&lt;/span&gt; &lt;span class="err"&gt;{%&lt;/span&gt; &lt;span class="na"&gt;endif&lt;/span&gt; &lt;span class="err"&gt;%}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;source&lt;/span&gt; &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"{{id}}.webp"&lt;/span&gt; &lt;span class="err"&gt;{%&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt; &lt;span class="na"&gt;alt&lt;/span&gt; &lt;span class="err"&gt;%}&lt;/span&gt;&lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"{{alt}}"&lt;/span&gt; &lt;span class="err"&gt;{%&lt;/span&gt; &lt;span class="na"&gt;endif&lt;/span&gt; &lt;span class="err"&gt;%}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"{{id}}.png"&lt;/span&gt; &lt;span class="err"&gt;{%&lt;/span&gt; &lt;span class="na"&gt;if&lt;/span&gt; &lt;span class="na"&gt;alt&lt;/span&gt; &lt;span class="err"&gt;%}&lt;/span&gt;&lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"{{alt}}"&lt;/span&gt; &lt;span class="err"&gt;{%&lt;/span&gt; &lt;span class="na"&gt;endif&lt;/span&gt; &lt;span class="err"&gt;%}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/picture&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Step 2: Rust tool
&lt;/h4&gt;

&lt;p&gt;The way I structure my posts is that each post lies neatly inside its own folder, along with all the images and any other extra assets that add some sort of value to the content.&lt;/p&gt;

&lt;p&gt;So, from the theme I grab all the &lt;code&gt;png&lt;/code&gt; images&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;input_paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Params&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"content/**/*.png"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
        &lt;span class="nf"&gt;.filter_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Params&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;should_recreate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="py"&gt;.recreate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="nn"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;default&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 trouble is the theme also contains some images which need to be converted. At the moment the only image is my logo&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;theme_image_paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Params&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"themes/**/*.jpg"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
        &lt;span class="nf"&gt;.filter_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Params&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;should_recreate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="py"&gt;.recreate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;should_resize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In order to save time, converting images that exist already is a bit redundant so the tool checks if the path exists already and if it does, conversion is skipped. Working with paths is surprisingly straightforward. I was previously quite afraid to write code in &lt;code&gt;rust&lt;/code&gt; because I feared the overhead.&lt;/p&gt;

&lt;p&gt;The actual code is stupid easy to understand and reason about, even for me&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;    &lt;span class="c1"&gt;//  webp file path&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;webp_file_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parent_dir&lt;/span&gt;&lt;span class="nf"&gt;.join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{}.webp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;file_stem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.as_str&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt; &lt;span class="c1"&gt;// Convert to .webp as an example&lt;/span&gt;
    &lt;span class="c1"&gt;// was it already converted?&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;webp_file_path&lt;/span&gt;&lt;span class="nf"&gt;.exists&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;A big chunk of the code lies in the conversion code which also gave me the most brain pain.&lt;br&gt;
We converted &lt;code&gt;webp&lt;/code&gt; using the &lt;a href="https://docs.rs/webp/latest/webp/" rel="noopener noreferrer"&gt;webp crate&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;convert_to_webp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;DynamicImage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AnyResult&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;encoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;WebpEncoder&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;webp_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="nf"&gt;.encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;75.0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Quality 75&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;File&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="nf"&gt;.write_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;webp_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Saved WebP to {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&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;And then &lt;code&gt;avif&lt;/code&gt; using the &lt;a href="https://crates.io/crates/ravif" rel="noopener noreferrer"&gt;avif crate&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;convert_to_avif&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;DynamicImage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AnyResult&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="nf"&gt;.dimensions&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="nf"&gt;.to_rgba8&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;encoded_avif&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Encoder&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.with_quality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;50.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.with_alpha_quality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;50.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.with_speed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.with_alpha_color_mode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;AlphaColorMode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;UnassociatedClean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.with_num_threads&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="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;avif_pixels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;
        &lt;span class="nf"&gt;.pixels&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;Rgba&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&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="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="py"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Rgba&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;EncodedImage&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;avif_file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;color_byte_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;alpha_byte_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="o"&gt;..&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;encoded_avif&lt;/span&gt;
        &lt;span class="nf"&gt;.encode_rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Img&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;avif_pixels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="nf"&gt;.try_into&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="nf"&gt;.try_into&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;File&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="nf"&gt;.write_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;avif_file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Saved AVIF to {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;Ok&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 reason converting to &lt;code&gt;avif&lt;/code&gt; was a bit more convoluted was due to the requirement for pixels to be in &lt;code&gt;rgba&lt;/code&gt; format. I had to convert the image to &lt;code&gt;rgba&lt;/code&gt; and then convert the pixels to &lt;code&gt;Rgba&lt;/code&gt; format(thank you libs with different types). This was a bit of a pain but I managed to get it working.&lt;/p&gt;

&lt;p&gt;I'm not an image processing expert so the solution was the result of a long conversation with trail and error with &lt;code&gt;ChatGPT&lt;/code&gt;, then again this is why I love &lt;code&gt;rust&lt;/code&gt; and how strict it is. It forces you to write code in a way that if it runs it most likely is correct.&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 3: Github action
&lt;/h4&gt;

&lt;p&gt;The action installs and enables &lt;code&gt;rust&lt;/code&gt; so that the cli can be used&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install Rust&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y&lt;/span&gt;
    &lt;span class="s"&gt;rustup toolchain install nightly&lt;/span&gt;
    &lt;span class="s"&gt;rustup default nightly&lt;/span&gt;
    &lt;span class="s"&gt;echo "$HOME/.cargo/bin" &amp;gt;&amp;gt; $GITHUB_PATH&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Verify Rust Installation&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;rustup --version&lt;/span&gt;
    &lt;span class="s"&gt;rustc --version&lt;/span&gt;
    &lt;span class="s"&gt;cargo --version&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build CLI tool&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;cargo build --manifest-path ./helpers/image-optimizer/Cargo.toml --release --verbose&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Perform the optimization&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./helpers/image-optimizer/target/release/image-optimizer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing I particularly liked was the fact that it is possible to use a relative path to &lt;code&gt;Cargo.toml&lt;/code&gt; which means no mucking about with paths.&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 4: Caching
&lt;/h4&gt;

&lt;p&gt;Now one thing about &lt;code&gt;rust&lt;/code&gt; that is a bit of a bummer is that builds take quite some time. I guess that is the price to pay for static memory analysis. I would love to do a deep dive at some point on the optimization of rust build times but that is a story for another time.&lt;/p&gt;

&lt;p&gt;The one thing that &lt;code&gt;github&lt;/code&gt; tends to hold you accountable for is the number of build minutes you use when a workflow runs, so having rust install itself, download dependencies and then run a build for the tools can quickly add up.&lt;/p&gt;

&lt;p&gt;The optimization for build times covers caching cargo dependencies but also caching the built binary.&lt;/p&gt;

&lt;p&gt;I cached most things that I was able to but I am getting mixed results when trying to cache apt packages. It simply does not seem to work as intended in the naive approach.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install OS Dependencies (if needed)&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;# Create a file listing your required packages, one per line.&lt;/span&gt;
    &lt;span class="s"&gt;cat &amp;gt; apt-packages.txt &amp;lt;&amp;lt; EOF&lt;/span&gt;
    &lt;span class="s"&gt;nasm&lt;/span&gt;
    &lt;span class="s"&gt;EOF&lt;/span&gt;
    &lt;span class="s"&gt;sudo apt-get update&lt;/span&gt;
    &lt;span class="s"&gt;sudo apt-get install -y --no-install-recommends $(cat apt-packages.txt)&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cache Rust toolchain&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache@v3&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;~/.rustup&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ runner.os }}-rustup-${{ hashFiles('rust-toolchain') }}&lt;/span&gt;
    &lt;span class="na"&gt;restore-keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;${{ runner.os }}-rustup-&lt;/span&gt;

  &lt;span class="s"&gt;...&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cache Cargo dependencies and target&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/cache@v3&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;~/.cargo/registry&lt;/span&gt;
      &lt;span class="s"&gt;~/.cargo/git&lt;/span&gt;
      &lt;span class="s"&gt;./helpers/image-optimizer/target&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ runner.os }}-manual-cargo-${{ hashFiles('**/Cargo.lock') }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The savings in time by using the caching is quite substantial. The first run of the workflow took 15 minutes, the run that had cached the deps was less than 1 minute. Even with a substantial amount of images this will most likely not be a bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Optimizing images can decrease load on the server up to 70%&lt;/li&gt;
&lt;li&gt;... and it also improves performance which is beneficial for SEO&lt;/li&gt;
&lt;li&gt;Optimizing github workflows can save you a lot of wait time&lt;/li&gt;
&lt;li&gt;... and github minutes&lt;/li&gt;
&lt;li&gt;While this was interesting to do, I optimized for something that did not move the needle at all, it just made the evaluation in the &lt;code&gt;Google Search Console&lt;/code&gt; a bit better.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is what I have to blame my OCD for. I am happy with the result and I learned quite a few things about &lt;code&gt;rust&lt;/code&gt; and &lt;code&gt;image&lt;/code&gt; processing. I am also happy that I managed to get the &lt;code&gt;avif&lt;/code&gt; conversion working as it is a format that is not yet widely supported but is the most efficient format out there.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>cdn</category>
      <category>seo</category>
      <category>avif</category>
    </item>
    <item>
      <title>Rustify some puppeteer code(part I)</title>
      <dc:creator>Artur Daschevici</dc:creator>
      <pubDate>Thu, 27 Jun 2024 15:30:00 +0000</pubDate>
      <link>https://dev.to/adaschevici/rustify-some-puppeteer-code-3n33</link>
      <guid>https://dev.to/adaschevici/rustify-some-puppeteer-code-3n33</guid>
      <description>&lt;h2&gt;
  
  
  Why?
&lt;/h2&gt;

&lt;p&gt;Rust is pretty amazing but there are a few things that you might be weary about. There are &lt;a href="https://discord.com/blog/why-discord-is-switching-from-go-to-rust" rel="noopener noreferrer"&gt;few war stories&lt;/a&gt; of companies building their entire stack on &lt;code&gt;rust&lt;/code&gt; or and then living happily ever after. Software is an ever evolving organism so in the &lt;a href="https://www.darwinproject.ac.uk/people/about-darwin/six-things-darwin-never-said/evolution-misquotation" rel="noopener noreferrer"&gt;darwinian sense the more adaptable the better&lt;/a&gt;. Enough of that though, not here to advocate any particular language or framework, what I want is to share my experience with writing an equivalent scraper in &lt;code&gt;rust&lt;/code&gt; to &lt;a href="https://dev.to/adaschevici/gopherizing-some-puppeteer-code-29g4"&gt;my previous post&lt;/a&gt; where I used &lt;code&gt;golang&lt;/code&gt; and &lt;code&gt;chromedp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The experience using &lt;code&gt;go&lt;/code&gt; with &lt;code&gt;chromedp&lt;/code&gt; to automate chrome was pretty good, it is not as powerful as what is available in &lt;code&gt;puppeteer&lt;/code&gt; so I figured I would have a look at what might be available in the &lt;code&gt;rust&lt;/code&gt; landscape.&lt;/p&gt;

&lt;h2&gt;
  
  
  What?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fueockjszks2z31802f0l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fueockjszks2z31802f0l.png" alt="Rust Puppeteering" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;rust&lt;/code&gt; there are several libraries that deal with browser automation, a few I have had a look at are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/jonhoo/fantoccini" rel="noopener noreferrer"&gt;fantocini&lt;/a&gt; - A high-level API for programmatically interacting with web pages through WebDriver, but I want chrome devtools protocol instead.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/rust-headless-chrome/rust-headless-chrome" rel="noopener noreferrer"&gt;rust-headless-chrome&lt;/a&gt; - chrome devtools protocol client library in rust, not as active as the crate I wound up using.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/mattsse/chromiumoxide" rel="noopener noreferrer"&gt;chromiumoxide&lt;/a&gt; - this is the one that seem to be the most active in terms of development so it looks like a good choice at time of writing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;As I was reading one of my older posts that focuses on quasi live coding I realized it was boring as hell, and if your attention span is that of a goldfish, like mine is, it would probably make sense to just drop in a link to the &lt;a href="https://github.com/adaschevici/rustic-toy-chest/tree/main/rust-crawl-pupp" rel="noopener noreferrer"&gt;repo&lt;/a&gt; so that you can download the code and try it out yourself. The repo is a collection of rust prototypes that I have been building for fun and learning, haven't had yet a compelling reason to use rust in production unfortunately 😢.&lt;/p&gt;

&lt;h2&gt;
  
  
  How?
&lt;/h2&gt;

&lt;p&gt;To my surprise the code was closer in structure to the &lt;a href="https://pptr.dev/" rel="noopener noreferrer"&gt;&lt;code&gt;puppeteer&lt;/code&gt;&lt;/a&gt; version than it was to the &lt;a href="https://github.com/chromedp/chromedp" rel="noopener noreferrer"&gt;&lt;code&gt;chromedp&lt;/code&gt;&lt;/a&gt;. The &lt;code&gt;chromedp&lt;/code&gt; version uses nested context declarations to manage the browser and page runtimes, the &lt;code&gt;rust&lt;/code&gt; version uses a more linear approach. You construct a browser instance and then you can interact with it as a user would. This points at the fact that the &lt;code&gt;chromiumoxide&lt;/code&gt; api is higher level. &lt;/p&gt;

&lt;p&gt;The way you can set things up to keep your use cases separate is by adding &lt;a href="https://docs.rs/clap/latest/clap/" rel="noopener noreferrer"&gt;&lt;code&gt;clap&lt;/code&gt;&lt;/a&gt; to your project and use command line flags to select the use case you want to run.&lt;/p&gt;

&lt;p&gt;You will see that I have covered most cases but not everything is transferable from &lt;code&gt;puppeteer&lt;/code&gt; or &lt;code&gt;chromedp&lt;/code&gt; to the &lt;code&gt;chromiumoxide&lt;/code&gt; version. I will not go through the setup of &lt;code&gt;rustup&lt;/code&gt;, rust toolchain or &lt;code&gt;cargo&lt;/code&gt; as this is a basic and well documented process, all you have to do is search for &lt;code&gt;getting started with rust&lt;/code&gt; and you will find a bunch of resources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Show me the code
&lt;/h2&gt;

&lt;h4&gt;
  
  
  1. Laying down the foundation
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;set up my project root&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo new rust-crawl-pupp
&lt;span class="nb"&gt;cd &lt;/span&gt;rust-crawl-pupp
cargo &lt;span class="nb"&gt;install &lt;/span&gt;cargo-edit &lt;span class="c"&gt;# this is useful for adding and upgrading dependencies&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;add dependencies via &lt;code&gt;cargo add&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[dependencies]&lt;/span&gt;
&lt;span class="py"&gt;chromiumoxide&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.5.7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"tokio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"tokio-runtime"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c"&gt;# this is the main dependency&lt;/span&gt;
&lt;span class="py"&gt;chromiumoxide_cdp&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.5.2"&lt;/span&gt; &lt;span class="c"&gt;# this is the devtools protocol&lt;/span&gt;
&lt;span class="py"&gt;clap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"4.5.7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"derive"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"cargo"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c"&gt;# this is for command line parsing&lt;/span&gt;
&lt;span class="py"&gt;futures&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.3.30"&lt;/span&gt; &lt;span class="c"&gt;# this is for async programming&lt;/span&gt;
&lt;span class="py"&gt;tokio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.38.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"full"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c"&gt;# this is the async runtime&lt;/span&gt;
&lt;span class="py"&gt;tracing&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.1.40"&lt;/span&gt; &lt;span class="c"&gt;# this is for logging&lt;/span&gt;
&lt;span class="py"&gt;tracing-subscriber&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.3.18"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"registry"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"env-filter"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c"&gt;# this is for logging&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;add &lt;code&gt;clap&lt;/code&gt; command line parsing to the project so that each different use case can be called via a subcommand&lt;br&gt;
define your imports&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;clap&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;Parser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Subcommand&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;define your command structs for parsing the command line arguments, this will allow for each use case to be called with its own subcommand like so &lt;code&gt;cargo run -- first-project&lt;/code&gt;, &lt;code&gt;cargo run -- second-project&lt;/code&gt;, and so on.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[derive(Parser)]&lt;/span&gt;
&lt;span class="nd"&gt;#[command(&lt;/span&gt;
    &lt;span class="nd"&gt;name&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"OxideCrawler"&lt;/span&gt;&lt;span class="nd"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;version&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.1"&lt;/span&gt;&lt;span class="nd"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;author&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"artur"&lt;/span&gt;&lt;span class="nd"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;about&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"An example application using clap"&lt;/span&gt;
&lt;span class="nd"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Cli&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;#[command(subcommand)]&lt;/span&gt;
    &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Commands&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nd"&gt;#[derive(Subcommand,&lt;/span&gt; &lt;span class="nd"&gt;Debug)]&lt;/span&gt;
&lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;Commands&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;FirstProject&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
    &lt;span class="n"&gt;SecondProject&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;the way you can hook this into the main function is via a &lt;code&gt;match&lt;/code&gt; statement that will call the appropriate function based on the subcommand that was passed in.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Cli&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="py"&gt;.command&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nn"&gt;Commands&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;FirstProject&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;user_agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;spoof_user_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nd"&gt;info!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"User agent detected"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  2. Starting browser and the browser cleanup
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;use the &lt;code&gt;launch&lt;/code&gt; method and its options to start the browser, if the viewport and window size are different, the browser will start in windowed mode, with the page size being smaller.&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Browser&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nn"&gt;BrowserConfig&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.with_head&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// this will start the browser in headless mode&lt;/span&gt;
        &lt;span class="nf"&gt;.no_sandbox&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// this will disable the sandbox&lt;/span&gt;
        &lt;span class="nf"&gt;.viewport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// this will set the viewport size&lt;/span&gt;
        &lt;span class="nf"&gt;.window_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// this will set the window size&lt;/span&gt;
        &lt;span class="nf"&gt;.build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;task&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;loop&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="nf"&gt;.next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&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="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;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;span class="nb"&gt;None&lt;/span&gt; &lt;span class="k"&gt;=&amp;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;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;the browser cleanup needs to be done correctly and there are two symptoms that you will see if you missed anything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the browser will not close - hangs at the end&lt;/li&gt;
&lt;li&gt;you might get a warning like the following:
&lt;/li&gt;
&lt;/ul&gt;

&lt;pre class="highlight shell"&gt;&lt;code&gt;  2024-06-26T08:40:01.418414Z  WARN chromiumoxide::browser: Browser was not closed manually, it will be killed automatically &lt;span class="k"&gt;in &lt;/span&gt;the background
&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;to correctly clean up your browser instance you will have to call these on the code paths that close the browser&lt;br&gt;
&lt;/p&gt;

&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="nf"&gt;.close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="nf"&gt;.wait&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;
  
  
  3. Use cases
&lt;/h4&gt;

&lt;p&gt;In the &lt;a href="https://github.com/adaschevici/rustic-toy-chest/tree/main/rust-crawl-pupp" rel="noopener noreferrer"&gt;repo&lt;/a&gt; each use case lives in its own module most of the time. There are some cases where you might have two living in the same module when they are very closely related, like in Use Case &lt;code&gt;c.&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;a. Spoof your user agent:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The only way I have found to set your user agent was from the &lt;a href="https://docs.rs/chromiumoxide/latest/chromiumoxide/page/struct.Page.html#" rel="noopener noreferrer"&gt;&lt;code&gt;Page&lt;/code&gt;&lt;/a&gt; module via the &lt;code&gt;set_user_agent&lt;/code&gt; method&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="nf"&gt;.new_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"about:blank"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="nf"&gt;.set_user_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s"&gt;"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) &lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;
       Chrome/58.0.3029.110 Safari/537.36"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="nf"&gt;.goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://www.whatismybrowser.com/detect/what-is-my-user-agent"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;b. Grabbing the full content of the page&lt;/strong&gt; is pretty straightforward&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;      &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;
        &lt;span class="nf"&gt;.new_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://scrapingclub.com/exercise/list_infinite_scroll/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="nf"&gt;.content&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;c. Grabbing elements via css selectors&lt;/strong&gt;,&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;elements_on_page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="nf"&gt;.find_elements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;".post"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;elements&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;elements_on_page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nf"&gt;.then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;el_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="nf"&gt;.inner_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="nf"&gt;.ok&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
          &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;el_text&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="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="nb"&gt;None&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;None&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="nf"&gt;.filter_map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="py"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;performing &lt;strong&gt;relative selection&lt;/strong&gt; from a specific node and mapping the content to &lt;code&gt;rust&lt;/code&gt; types&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;  &lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;product_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="nf"&gt;.find_element&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"h4"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="nf"&gt;.inner_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;product_price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="nf"&gt;.find_element&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"h5"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="nf"&gt;.inner_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Product&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;product_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;product_price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="o"&gt;...&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;d. When the page has infinite scroll&lt;/strong&gt; you will have to scroll to the bottom of the page to be able to collect all the elements you are interested in. To achieve this you need to inject &lt;code&gt;javascript&lt;/code&gt; into the page context and trigger a run of the function. The &lt;code&gt;chromiumoxide&lt;/code&gt; api seems to have really decent support for this, I faced much less resistance than I did with &lt;code&gt;chromedp&lt;/code&gt; and &lt;code&gt;go&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;js_script&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;r#"
      async () =&amp;gt; {
        await new Promise((resolve, reject) =&amp;gt; {
          var totalHeight = 0;
          var distance = 300; // should be less than or equal to window.innerHeight
          var timer = setInterval(() =&amp;gt; {
            var scrollHeight = document.body.scrollHeight;
            window.scrollBy(0, distance);
            totalHeight += distance;

            if (totalHeight &amp;gt;= scrollHeight) {
              clearInterval(timer);
              resolve();
            }
          }, 500);
        });
    }
  "#&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;
      &lt;span class="nf"&gt;.new_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://scrapingclub.com/exercise/list_infinite_scroll/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="nf"&gt;.evaluate_function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;js_script&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;e. When you need to wait for an element to load&lt;/strong&gt;, this was not exactly part of the &lt;code&gt;chromiumoxide&lt;/code&gt; api so I had to hack it together. Given my limited rust expertise there probably a better way to do this but this is what I managed to come up with. If the async block runs over the timeout then the &lt;code&gt;element_result&lt;/code&gt; will be an error, otherwise poll the dom for the element we are looking for.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;  &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;time&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="o"&gt;...&lt;/span&gt;
  &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;element_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout_duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;loop&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="nf"&gt;.find_element&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;element&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;element&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
              &lt;span class="c1"&gt;// Wait for a short interval before checking again&lt;/span&gt;
              &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;time&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_millis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="k"&gt;.await&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="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  4. Fixtures to replicate various scenarios
&lt;/h4&gt;

&lt;p&gt;Some websites, actually most websites have some sort of delay for loading different parts of the page, in order to prevent blocking the entire page. To replicate this behavior fixtures can be used to inject nodes into the dom with a delay. For the more edge case scenarios I created fixtures to emulate edge behaviors while not actually having to remember a website that is live and behaves like that.&lt;/p&gt;

&lt;p&gt;The HTML is really basic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"container"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="c"&gt;&amp;lt;!-- New node will be appended here --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"script.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;script.js&lt;/code&gt; file is slightly more, but still fairly straightforward:&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DOMContentLoaded&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Function to create and append the new node&lt;/span&gt;
    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createDelayedNode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Create a new div element&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newNode&lt;/span&gt; &lt;span class="o"&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;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;div&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="c1"&gt;// Add some content to the new node&lt;/span&gt;
      &lt;span class="nx"&gt;newNode&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="s1"&gt;This is a new node added after a delay.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="c1"&gt;// Add some styles to the new node&lt;/span&gt;
      &lt;span class="nx"&gt;newNode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;padding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;10px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;newNode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;marginTop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;10px&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;newNode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;backgroundColor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#f0f0f0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;newNode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;border&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1px solid #ccc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;newNode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;come-find-me&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="c1"&gt;// Append the new node to the container&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;container&lt;/span&gt; &lt;span class="o"&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;container&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;container&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newNode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Set a delay (in milliseconds)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 3000ms = 3 seconds&lt;/span&gt;

    &lt;span class="c1"&gt;// Use setTimeout to create and append the node after the delay&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;createDelayedNode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delay&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;What it will do is create a new node with some text content and some styles, then append it to the container div after a delay of 3 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why to be continued?
&lt;/h2&gt;

&lt;p&gt;What I hate more than &lt;code&gt;to be continued&lt;/code&gt; in a TV show where I don't have the next episode available is a blog post that has code that looks reasonable and that it might work, but doesn't. So going by the lesser of two evils principle I decided to make this a two parter which will give me the time to write and test the other use cases in order to make sure everything works as expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusions
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;This is one of the few times I have stuck with &lt;code&gt;rust&lt;/code&gt; through the pain and I have to say it was a better experience than I had with &lt;code&gt;go&lt;/code&gt; and &lt;code&gt;chromedp&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;writing the code was slightly faster since there was less boilerplate to write&lt;/li&gt;
&lt;li&gt;messing around with wrappers and &lt;code&gt;unwrap()&lt;/code&gt; was challenging but probably in time it gets easier&lt;/li&gt;
&lt;li&gt;the code in &lt;code&gt;rust&lt;/code&gt; looks more like &lt;code&gt;puppeteer&lt;/code&gt; than the &lt;code&gt;go&lt;/code&gt; version did&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  In Part II I will cover dealing with bot protection, handling frames, forms and more. Stay tuned!
&lt;/h4&gt;

</description>
      <category>rust</category>
      <category>scraping</category>
      <category>webcrawling</category>
    </item>
    <item>
      <title>OpenAI api RAG system with Qdrant</title>
      <dc:creator>Artur Daschevici</dc:creator>
      <pubDate>Wed, 19 Jun 2024 12:35:00 +0000</pubDate>
      <link>https://dev.to/adaschevici/openai-api-rag-system-with-qdrant-7km</link>
      <guid>https://dev.to/adaschevici/openai-api-rag-system-with-qdrant-7km</guid>
      <description>&lt;h2&gt;
  
  
  Why?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://openai.com/" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt; has been making it easier and easier to build out &lt;a href="https://www.deeplearning.ai/the-batch/how-agents-can-improve-llm-performance/" rel="noopener noreferrer"&gt;GPT agents&lt;/a&gt; that make use of your own data to improve the generated responses of the pretrained models.&lt;/p&gt;

&lt;p&gt;Agents give a way to inject knowledge about your specific proprietary data into your pipeline, without actually sharing any private information about it. You can also improve the recency of your data too which makes you less dependent on the model's training cycle.&lt;/p&gt;

&lt;p&gt;OpenAI has improved the DX, UX and APIs since version 3.5, and has made it easier to create &lt;code&gt;agents&lt;/code&gt; and embed your data into your custom &lt;a href="https://openai.com/index/introducing-gpts/" rel="noopener noreferrer"&gt;&lt;code&gt;GPTs&lt;/code&gt;&lt;/a&gt;. They have lowered the barrier to entry which means that virtually anyone can build their own assistants that would be able to respond to queries about their data. This is perfect for people to experiment on building products. IMO this is a very good approach to enable product discovery for the masses.&lt;/p&gt;

&lt;p&gt;Most big AI contenders on the market provide you with a toolbox of high level abstractions and low to no code solutions. The weird thing about my approach to learning things is that not having some understanding of the first principles of the tech I'm using makes me feel a bit helpless, this is why I figured trying to build my own &lt;code&gt;RAG&lt;/code&gt; system would be a good way to figure out the nuts and bolts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What?
&lt;/h2&gt;

&lt;p&gt;I wanted to get a project for running my own pipeline with somewhat interchangeable parts. Models can be swapped around so that you can make the most of the latest models either available on &lt;a href="https://huggingface.co/" rel="noopener noreferrer"&gt;&lt;code&gt;Hugginface&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://openai.com/" rel="noopener noreferrer"&gt;&lt;code&gt;OpenAI&lt;/code&gt;&lt;/a&gt; or wherever.&lt;/p&gt;

&lt;p&gt;Because things are moving so fast in model research the top contenders are surpassing each other every day pretty much. A custom pipeline  would allow us to quickly iterate and test out new models as they evolve. This allows you to try out new models and just as easily rollback your experiment.&lt;/p&gt;

&lt;p&gt;What I wound up building is a &lt;a href="https://streamlit.io/" rel="noopener noreferrer"&gt;&lt;code&gt;Streamlit&lt;/code&gt;&lt;/a&gt; app that uses &lt;a href="https://qdrant.com/" rel="noopener noreferrer"&gt;&lt;code&gt;qdrant&lt;/code&gt;&lt;/a&gt; to index and search data extracted from a collection of &lt;code&gt;pdf&lt;/code&gt; document. The app is a simple chat interface where you can ask questions about the data and get responses from a mixture of &lt;code&gt;GPT-4&lt;/code&gt; and the indexed data.&lt;/p&gt;

&lt;h2&gt;
  
  
  How?
&lt;/h2&gt;

&lt;h4&gt;
  
  
  1. Setting up the environment
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;use &lt;code&gt;pyenv&lt;/code&gt; to manage python versions
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="c"&gt;# update versions&lt;/span&gt;
   pyenv update
   &lt;span class="c"&gt;# install any python version&lt;/span&gt;
   pyenv &lt;span class="nb"&gt;install &lt;/span&gt;3.12.3 &lt;span class="c"&gt;# as of writing this&lt;/span&gt;
   &lt;span class="c"&gt;# create a virtualenv&lt;/span&gt;
   ~/.pyenv/versions/3.12.3/bin/python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
   &lt;span class="c"&gt;# and then activate it&lt;/span&gt;
   &lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  2. Install the dependencies
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   &lt;span class="c"&gt;# install poetry&lt;/span&gt;
   pip &lt;span class="nb"&gt;install &lt;/span&gt;poetry
   &lt;span class="c"&gt;# install the dependencies&lt;/span&gt;
   poetry &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the dependencies section of the &lt;code&gt;pyproject.toml&lt;/code&gt; file should look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;   &lt;span class="err"&gt;...&lt;/span&gt;
   &lt;span class="nn"&gt;[tool.poetry.dependencies]&lt;/span&gt;
    &lt;span class="py"&gt;python&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"^3.12"&lt;/span&gt;
    &lt;span class="py"&gt;streamlit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"^1.32.1"&lt;/span&gt;
    &lt;span class="py"&gt;langchain&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"^0.1.12"&lt;/span&gt;
    &lt;span class="py"&gt;python-dotenv&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"^1.0.1"&lt;/span&gt;
    &lt;span class="py"&gt;qdrant-client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"^1.8.0"&lt;/span&gt;
    &lt;span class="py"&gt;openai&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"^1.13.3"&lt;/span&gt;
    &lt;span class="py"&gt;huggingface-hub&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"^0.21.4"&lt;/span&gt;
    &lt;span class="py"&gt;pydantic-settings&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"^2.2.1"&lt;/span&gt;
    &lt;span class="py"&gt;pydantic&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"^2.6.4"&lt;/span&gt;
    &lt;span class="py"&gt;pypdf2&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"^3.0.1"&lt;/span&gt;
    &lt;span class="py"&gt;langchain-community&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"^0.0.28"&lt;/span&gt;
    &lt;span class="py"&gt;langchain-core&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"^0.1.31"&lt;/span&gt;
    &lt;span class="py"&gt;langchain-openai&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"^0.0.8"&lt;/span&gt;
    &lt;span class="py"&gt;instructorembedding&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"^1.0.1"&lt;/span&gt;
    &lt;span class="py"&gt;sentence-transformers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"2.2.2"&lt;/span&gt;
   &lt;span class="err"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  3. Set up the loading of the variables from a config file
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;a nice way to manage settings is to use &lt;code&gt;pydantic&lt;/code&gt; and &lt;code&gt;pydantic-settings&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;   &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SecretStr&lt;/span&gt;
   &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_settings&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseSettings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SettingsConfigDict&lt;/span&gt;

   &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseSettings&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
       &lt;span class="n"&gt;model_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SettingsConfigDict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env_file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;config.env&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;env_file_encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="n"&gt;hf_access_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SecretStr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HUGGINGFACEHUB_API_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="n"&gt;openai_api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SecretStr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OPENAI_API_KEY&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;this way you can load the settings from &lt;code&gt;config.env&lt;/code&gt; but variables in the environment override the ones in the file.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a nice extra is that you also get type checking and validation from &lt;code&gt;pydantic&lt;/code&gt; including &lt;code&gt;SecretStr&lt;/code&gt; types for sensitive data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  4. Set up the UI elements
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;Streamlit makes it quite easy to strap together a layout for your app. You have a single script that can run via the streamlit binary:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;   streamlit run app.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://streamlit.io/components?category=all" rel="noopener noreferrer"&gt;The gallery&lt;/a&gt; has many examples of various integrations and components that you can use to build your app. You have smaller components like inputs and buttons but also more complex UI tables, charts, you even have &lt;a href="https://streamlit.io/components?category=llms" rel="noopener noreferrer"&gt;&lt;code&gt;ChatGPT&lt;/code&gt;&lt;/a&gt; style templates.&lt;/p&gt;

&lt;p&gt;For our chat interface we require very few elements. Generally to create them you only need to use streamlit to initialize the UI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;   &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;streamlit&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;
   &lt;span class="bp"&gt;...&lt;/span&gt;
   &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
       &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ChatGPT-4 Replica&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ask me anything about the data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="n"&gt;question&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text_input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ask me anything&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;button&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ask&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
           &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;m thinking...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
           &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
           &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="bp"&gt;...&lt;/span&gt;
   &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one thing I find a bit awkward is the fact that if you have elements that need to be conditionally displayed the conditions tend to resemble the javascript pyramid of doom if you have too many conditionals in the same block.&lt;/p&gt;

&lt;p&gt;Below is a simple example so you can see what I mean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;   &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_docs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
       &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Please upload some PDFs to start chatting.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
       &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sidebar&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
           &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;button&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Process&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
               &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spinner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Processing...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                   &lt;span class="c1"&gt;# get raw content from pdf
&lt;/span&gt;                   &lt;span class="n"&gt;raw_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_text_from_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_docs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                   &lt;span class="n"&gt;text_chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_text_chunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

                   &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vector_store&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                       &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                       &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vector_store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_vector_store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text_chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                       &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                       &lt;span class="c1"&gt;# create vector store for each chunk
&lt;/span&gt;                       &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Time taken to create vector store: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="si"&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;This makes me think that it is probably not designed for complex UIs but rather for quick prototyping and simple interfaces.&lt;/p&gt;

&lt;h4&gt;
  
  
  5. pdf data extraction
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;I used the &lt;code&gt;PyPDF2&lt;/code&gt; library to extract the text from the pdfs. The library is quite simple to use and you can extract the text from a pdf file with a few lines of code.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;   &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PyPDF2&lt;/span&gt;

   &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_text_from_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_docs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
       &lt;span class="n"&gt;raw_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
       &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pdf_docs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
           &lt;span class="n"&gt;pdf_file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
           &lt;span class="n"&gt;pdf_reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PyPDF2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PdfFileReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
           &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;page_num&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pdf_reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;numPages&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
               &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pdf_reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page_num&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
               &lt;span class="n"&gt;raw_text&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extract_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
       &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;raw_text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;The extracted text should be chunked into smaller pieces that can be used to create embeddings for the &lt;code&gt;qdrant&lt;/code&gt; index.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;   &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_text_chunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
       &lt;span class="n"&gt;text_chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
       &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_text&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
           &lt;span class="n"&gt;text_chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
       &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;text_chunks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  6. Setting up the &lt;code&gt;qdrant&lt;/code&gt; server via &lt;code&gt;docker&lt;/code&gt;
&lt;/h4&gt;

&lt;p&gt;The best way to set up &lt;code&gt;qdrant&lt;/code&gt; is to use docker and to keep track of the environment setup &lt;code&gt;docker-compose&lt;/code&gt; is a nice approach. You can set up the &lt;code&gt;qdrant&lt;/code&gt; server with a simple &lt;code&gt;docker-compose.yml&lt;/code&gt; file like the one below:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;   &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.9'&lt;/span&gt;

   &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
     &lt;span class="na"&gt;qdrant&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
       &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;qdrant/qdrant:latest&lt;/span&gt;
       &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
         &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;6333:6333"&lt;/span&gt; &lt;span class="c1"&gt;# Expose Qdrant on port 6333 of the host&lt;/span&gt;
       &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
         &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;qdrant_data:/qdrant/data&lt;/span&gt; &lt;span class="c1"&gt;# Persistent storage for Qdrant data&lt;/span&gt;
       &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
         &lt;span class="na"&gt;RUST_LOG&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;info"&lt;/span&gt; &lt;span class="c1"&gt;# Set logging level to info&lt;/span&gt;

   &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
     &lt;span class="na"&gt;qdrant_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
       &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;qdrant_data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  7. Indexing the data
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;qdrant&lt;/code&gt; client can be used to index the embeddings and perform similarity search on the data. You can pick and choose the best model for embeddings for your data and swap them out if you find &lt;a href="https://huggingface.co/spaces/mteb/leaderboard" rel="noopener noreferrer"&gt;a better one&lt;/a&gt;.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;   &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_vector_store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text_chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qdrant_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:6333&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
       &lt;span class="n"&gt;embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HuggingFaceInstructEmbeddings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;avsolatorio/GIST-Embedding-v0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model_kwargs&lt;/span&gt;&lt;span class="o"&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;device&lt;/span&gt;&lt;span class="sh"&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;mps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
       &lt;span class="n"&gt;vector_store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Qdrant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
           &lt;span class="n"&gt;text_chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;qdrant_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pdfs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="n"&gt;force_recreate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;vector_store&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  8. sending the query
&lt;/h4&gt;

&lt;p&gt;In order to send the query to &lt;code&gt;qdrant&lt;/code&gt; you again need to embed it to allow to do a similarity search over your collection of documents.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;   &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qdrant_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:6333&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HuggingFaceInstructEmbeddings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;avsolatorio/GIST-Embedding-v0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model_kwargs&lt;/span&gt;&lt;span class="o"&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;device&lt;/span&gt;&lt;span class="sh"&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;mps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="n"&gt;query_vector&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;vector_store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Qdrant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;qdrant_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pdfs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vector_store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  9. Analysis
&lt;/h4&gt;

&lt;p&gt;You can swap out any of the components in this project with something else. You could use &lt;a href="https://github.com/facebookresearch/faiss" rel="noopener noreferrer"&gt;&lt;code&gt;Faiss&lt;/code&gt;&lt;/a&gt; instead of &lt;code&gt;qdrant&lt;/code&gt;, you could use &lt;code&gt;OpenAI&lt;/code&gt; models for everything(embeddings/chat completion) or you could use open models.&lt;/p&gt;

&lt;p&gt;You can forego the UI and simply use &lt;code&gt;fastapi&lt;/code&gt; to create an API to interact with the PDF documents. I hope this gives you some sense of the possibilities that are available to you when building your own &lt;code&gt;RAG&lt;/code&gt; system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusions
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;you can build your own agent and have it respond to queries about your data quite easily&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;streamlit&lt;/code&gt; is great for prototyping and building out simple interfaces&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;qdrant&lt;/code&gt; is good for performing similarity search on your data&lt;/li&gt;
&lt;li&gt;when building &lt;code&gt;RAG&lt;/code&gt; systems you need to make use of embedding models to encode your data&lt;/li&gt;
&lt;li&gt;embedding models are the most taxing parts of the pipeline&lt;/li&gt;
&lt;li&gt;if you have pluggable parts in your pipeline you can swap them out easily to save costs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pydantic&lt;/code&gt; and &lt;code&gt;pydantic-settings&lt;/code&gt; are great for adding type checking and validation to your python code&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>openai</category>
      <category>opensource</category>
      <category>langchain</category>
      <category>rag</category>
    </item>
    <item>
      <title>Converging project boilerplates with copier</title>
      <dc:creator>Artur Daschevici</dc:creator>
      <pubDate>Tue, 11 Jun 2024 10:00:00 +0000</pubDate>
      <link>https://dev.to/adaschevici/converging-project-boilerplates-with-copier-f62</link>
      <guid>https://dev.to/adaschevici/converging-project-boilerplates-with-copier-f62</guid>
      <description>&lt;h2&gt;
  
  
  Why?
&lt;/h2&gt;

&lt;p&gt;Technically when you start a new project the best way to approach it is by using the &lt;code&gt;CLI&lt;/code&gt; tool of the realm, such as &lt;code&gt;svelte-kit&lt;/code&gt;, &lt;code&gt;astro&lt;/code&gt;, &lt;code&gt;django-cli&lt;/code&gt; etc..., you get the idea. The huge bonus to doing this is that you get the best practices baked in and as new standards are created the &lt;code&gt;CLI&lt;/code&gt; gets updated.&lt;/p&gt;

&lt;p&gt;So far the frontend has been a lot luckier with the tools as far as project generation goes, every major framework having come out with their own project generation tool, some having more than one possibly due to multiple schools of thought.&lt;/p&gt;

&lt;p&gt;There are some backend frameworks that have project generation tools too but so far it seems to be difficult to agree on the structure. The best you can do is find a way to structure it that looks like the majority and makes sense for you. I have been building spiders and crawlers for data ingestion pipelines using &lt;code&gt;python&lt;/code&gt; at first and then &lt;code&gt;node&lt;/code&gt; and &lt;code&gt;go&lt;/code&gt;. Even more recently I have been looking at hacking out some tweaks in some of my &lt;code&gt;neovim&lt;/code&gt; plugins(that is lua).&lt;/p&gt;

&lt;p&gt;For example neovim plugins have a pretty standard setup, they will have a folder layout something like the following:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scratcher.nvim/
├── README.md
├── lua
│&amp;nbsp;&amp;nbsp; └── scratcher
│&amp;nbsp;&amp;nbsp;     └── init.lua
└── plugin
    └── scratcher.lua
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The standard way of naming things seems to be gravitating towards having some conventions as you can see so setting up a new plugin would be pretty much repetitive and automatable. And it will probably save you some time and willpower in the long run, provided you have some sense of what your final architecture needs to look like.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fra56uo9dojnhnd8z5ppx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fra56uo9dojnhnd8z5ppx.png" alt="Clones and copies" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How?
&lt;/h2&gt;

&lt;p&gt;If you are coming from &lt;code&gt;python&lt;/code&gt; like I am then you may already  be familiar with &lt;a href="https://github.com/cookiecutter/cookiecutter" rel="noopener noreferrer"&gt;&lt;code&gt;cookiecutter&lt;/code&gt;&lt;/a&gt;. I have been in the situation a few times where it might have made sense to use it, but every time it was a matter of balancing out the timeline and trying to stay away from over engineering.&lt;/p&gt;

&lt;p&gt;Lately though the stuff I have been dealing with has been slightly on the more experimental side so churning out something new is something that happens quite often, so it makes more sense to have a prebaked architecture for specific project styles.&lt;/p&gt;

&lt;p&gt;From the project templating libraries I was aware of &lt;a href="https://github.com/cookiecutter/cookiecutter" rel="noopener noreferrer"&gt;&lt;code&gt;cookiecutter&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://copier.readthedocs.io/en/stable/" rel="noopener noreferrer"&gt;&lt;code&gt;copier&lt;/code&gt;&lt;/a&gt;. &lt;code&gt;cookie-cutter&lt;/code&gt; uses &lt;code&gt;json&lt;/code&gt; for driving the generation while &lt;code&gt;copier&lt;/code&gt; uses &lt;code&gt;yaml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In the end I used copier as I tend to favor &lt;code&gt;yaml&lt;/code&gt; because it allows for comments in the config. It makes it easy to plop random pieces of info or even docs in there. Since the config files driving the wizard it can become quite convoluted and also difficult to read as you would normal code so having the ability to document different options is probably a plus I would think.&lt;/p&gt;

&lt;p&gt;The library allows you to build a sort of setup wizard where you can set up your desired flow of questions, and you can use the choices supplied to drive what folders will be used in the final project boilerplate. This is pretty nifty as it gives you the ability to customize stuff all the way down to the build process.&lt;/p&gt;

&lt;p&gt;Another neat thing is that when you have decent chunks of code that can be shared, so you can just put that in your boilerplate, so it will essentially give you things just the way you like them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cherry pick of features
&lt;/h2&gt;

&lt;p&gt;There are a few notable features that I would kick myself if I didn't mention:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;you can define choices for your options by using the &lt;code&gt;choices&lt;/code&gt; key in your &lt;code&gt;copier.yml&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;project_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;str&lt;/span&gt;
&lt;span class="na"&gt;help&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;What type of project are you creating?&lt;/span&gt;
&lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;neovim-plugin&lt;/span&gt;
&lt;span class="na"&gt;choices&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;neovim-plugin&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;golang-cli&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;python-cli&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;you can include different files in the root level &lt;code&gt;copier.yml&lt;/code&gt; thus breaking down the wizard in composable parts, for example the CI/CD parts can be shared across projects&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="kt"&gt;!include&lt;/span&gt; &lt;span class="s"&gt;shared-conf/ci-cd.*.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;&lt;p&gt;defaults are very powerful and can also make use of current runtime context which is quite nice. Essentially you can think of it as a way to have your very own project wizard that is tweaked for every one of your needs.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cool use-cases
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;you can define a folder/file be created conditionally depending on an option selection eg:&lt;/p&gt;

&lt;p&gt;You define your &lt;code&gt;copier.yml&lt;/code&gt; like this to give you a choice into the type of project:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;project_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;str&lt;/span&gt;
&lt;span class="na"&gt;help&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;What type of project are you creating?&lt;/span&gt;
&lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;neovim-plugin&lt;/span&gt;
&lt;span class="na"&gt;choices&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;neovim-plugin&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;golang-cli&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;python-cli&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;you then create a conditionally rendered folder, the naming follows &lt;code&gt;jinja&lt;/code&gt; templating rules, so it might look something like the following&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;{&lt;/span&gt;% &lt;span class="k"&gt;if &lt;/span&gt;project_type &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s1"&gt;'neovim-plugin'&lt;/span&gt; %&lt;span class="o"&gt;}{{&lt;/span&gt;project_name&lt;span class="o"&gt;}}&lt;/span&gt;.nvim&lt;span class="o"&gt;{&lt;/span&gt;% endif %&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;It looks a bit strange, it will probably not work on Windows and it might look daunting at first but hopefully you will only need to revisit the hierarchy when you update your project structure template. This is not something I would expect to happen very often.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;in more advanced use cases you may need to write some custom python code for transforming/processing of entities in your &lt;code&gt;jinja&lt;/code&gt; templates, or template strings.&lt;br&gt;
To hook this in you need to enable the &lt;code&gt;jinja&lt;/code&gt; template extensions and add a separate package &lt;code&gt;copier-templates-extensions&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;_jinja_extensions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;copier_templates_extensions.TemplateExtensionLoader&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;extensions/context.py:ContextUpdater&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;This allows you to load specific extensions in your project generator runtime and it can serve different functions. The following snippet illustrates a way you can update the context:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;copier_templates_extensions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ContextHook&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ContextUpdater&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ContextHook&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;hook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;new_context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
        &lt;span class="n"&gt;new_context&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;onboarding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;first steps with &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project-type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;new_context&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;For example you might decide to create a file called "first steps with .txt" once the project is generated. In template form the file name would be &lt;code&gt;{{ onboarding }}&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusions:
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;you can build hybrid boilerplates for your project and drive generating the folder hierarchy from a single repo&lt;/li&gt;
&lt;li&gt;the notation is a bit weird with templated folder names, will not work on Win&lt;/li&gt;
&lt;li&gt;the templated naming is also very powerful allowing for conditional creation of folders&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>boilerplate</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Gopherizing some puppeteer code</title>
      <dc:creator>Artur Daschevici</dc:creator>
      <pubDate>Thu, 06 Jun 2024 22:00:00 +0000</pubDate>
      <link>https://dev.to/adaschevici/gopherizing-some-puppeteer-code-29g4</link>
      <guid>https://dev.to/adaschevici/gopherizing-some-puppeteer-code-29g4</guid>
      <description>&lt;h2&gt;
  
  
  Why?
&lt;/h2&gt;

&lt;p&gt;As developers we sometimes get a bad case of the shiny new object syndrome. I hate to say it but every time I start hacking on something new, the urge to add something new is quite overwhelming. It is really tough to keep an interest in projects for a long time and it starts to become tedious the deeper you go into the weeds. I suppose this is why people list &lt;code&gt;growth&lt;/code&gt; as one of their top motivations.&lt;/p&gt;

&lt;p&gt;I consider that anything new is an opportunity for growth, and doing something over and over in a similar manner quickly becomes a tedious. I've been building various types of scrapers since 2011, and it all started because I wanted to automate a workflow and save myself some time. The time spent on automating this was probably more than if I had done this by hand but it was interesting interacting via &lt;code&gt;http&lt;/code&gt; from code and crunching the data automatically.&lt;/p&gt;

&lt;p&gt;The amount of data on the web is pretty crazy, you have various sources and multiple types of data that can be combined in very interesting ways. Back in those days dropshipping was becoming huge and people were performing arbitrage across Amazon/Ebay/local flea-markets etc.. Tools that were able to perform analytics across these shops were quite trendy, and the market was slightly less crowded, so for me building crawlers seemed like a nice idea to build out a good customer base.&lt;/p&gt;

&lt;p&gt;Nowadays due to &lt;code&gt;RAG&lt;/code&gt; systems, gathering data automatically, breaking it down and feeding it into embedding models and storing it in vector databases for &lt;code&gt;LLM&lt;/code&gt; information enhancement has come back into the spotlight. In between then and now there have been a few changes in the way data is served up for consumption. Off the top of my head:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;single page apps have gained huge traction, most everyone turning to building their content in a &lt;code&gt;js&lt;/code&gt; bundle, loading everything on the fly as the page loads&lt;/li&gt;
&lt;li&gt;websites have become fussy about having their data used by unknown parties, so they have been closing down access and have become very litigious(#TODO: maybe add some cases of court cases Linkedin vs those guys, Financial Times vs OpenAI)&lt;/li&gt;
&lt;li&gt;bot detection and prevention - this one is funny since it is like a flywheel, it built 2 lucrative markets overnight - bot services and anti bot protection&lt;/li&gt;
&lt;li&gt;TBH, it's difficult to predict where this might be heading, it kind of feels like people have been aiming to move all their datas into data centers but since data is becoming so guarded...will they move back to paper?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7ba7923qyfb4tlkhatim.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7ba7923qyfb4tlkhatim.png" alt="All your data are belong to us" width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Because of &lt;code&gt;SPAs&lt;/code&gt; and the wide adoption of &lt;code&gt;js&lt;/code&gt; in websites it is much more convenient to use some sort of browser automation to crawl pages and extract the information. This makes it less prone to badgering the servers, and having to reverse engineer the page content loading, so you will probably want to use either a &lt;a href="https://chromedevtools.github.io/devtools-protocol/" rel="noopener noreferrer"&gt;&lt;code&gt;chrome developer tools protocol&lt;/code&gt;&lt;/a&gt; or &lt;a href="https://www.w3.org/TR/webdriver/" rel="noopener noreferrer"&gt;&lt;code&gt;webdriver&lt;/code&gt;&lt;/a&gt; flavored communications protocol with the browser. Back in the day IIRC I have also used the &lt;a href="https://www.riverbankcomputing.com/software/pyqt/intro" rel="noopener noreferrer"&gt;&lt;code&gt;PyQt&lt;/code&gt;&lt;/a&gt; bindings for acessing the &lt;code&gt;Qt&lt;/code&gt; browser component but nowadays its mostly straight-up browsers.&lt;/p&gt;

&lt;p&gt;These days my goto is &lt;code&gt;puppeteer&lt;/code&gt;. It's a weird tool that can be easily be used to scrape data from pages. The reason I say it is weird is mainly due to the deceiving nature of the internals, essentially using two &lt;code&gt;js&lt;/code&gt; engines that communicate via the &lt;code&gt;cdp&lt;/code&gt; protocol that is a a very dense beast and does not play nice with complex objects.&lt;/p&gt;

&lt;p&gt;Recently it has become more appealing to me to use strongly typed languages. This is probably because I have started to narrow down my experiments to very small code samples that illustrate one thing at time. I would go as far as to call it experiment driven development. Duck typing is fun as you can print pretty much anything you want. I was thinking to use &lt;code&gt;rust&lt;/code&gt; but it has a very tough learning curve. Node is pretty nice with &lt;code&gt;mjs&lt;/code&gt; but it's confusing sometimes when it crosses over between the two event loops, also while it is good for communicating on &lt;code&gt;cdp&lt;/code&gt; it is not really designed for sync code and &lt;code&gt;python&lt;/code&gt; is a bit boring for me so I decided to look at &lt;code&gt;go&lt;/code&gt;. Since it is a google language I expected it to have decent support for cdp, and the learning curve is slightly gentler than &lt;code&gt;rust&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How?
&lt;/h2&gt;

&lt;p&gt;Looking at the alternatives there are two that stand out &lt;a href="https://github.com/chromedp/chromedp" rel="noopener noreferrer"&gt;&lt;code&gt;chromedp&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://go-rod.github.io/#/" rel="noopener noreferrer"&gt;rod&lt;/a&gt;. Rod looks like it is the prodigal son of &lt;a href="https://behave.readthedocs.io/en/latest/" rel="noopener noreferrer"&gt;&lt;code&gt;behave&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://cucumber.io/" rel="noopener noreferrer"&gt;&lt;code&gt;cucumber&lt;/code&gt;&lt;/a&gt; some well established BDD frameworks. Personally I am not finding the &lt;code&gt;MustYaddaYadda...&lt;/code&gt; very readable and combining it with other custom APIs would probably make it become inconsistent. It has a few nice things in the way it abstracts &lt;code&gt;iframes&lt;/code&gt; but I am just unable to go past the higher level API.&lt;/p&gt;

&lt;p&gt;In the end I wound up choosing &lt;code&gt;chromedp&lt;/code&gt;. It works pretty well for most use cases, there are some places where it doesn't quite cut it and I wish it did, but by now I have come to terms there is no one technology to rule them all, wouldn't it be nice if that existed?&lt;/p&gt;

&lt;p&gt;You can install it via &lt;code&gt;go get -u github.com/chromedp/chromedp&lt;/code&gt; and then you can start using it in your code. It has quite a few submodules and related projects that you may want to use depending on your concrete use case.&lt;br&gt;
Generally if your use case is only data extraction and you have no tricky actions to deal with(page is &lt;em&gt;bot resistant&lt;/em&gt;, some elements are loaded at later times, &lt;code&gt;iframe&lt;/code&gt; hell etc...).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"context"&lt;/span&gt;
    &lt;span class="s"&gt;"log"&lt;/span&gt;
    &lt;span class="s"&gt;"time"&lt;/span&gt;

    &lt;span class="s"&gt;"github.com/chromedp/chromedp"&lt;/span&gt;
    &lt;span class="s"&gt;"github.com/chromedp/cdproto/cdp"&lt;/span&gt;
    &lt;span class="c"&gt;// for slightly more advanced use cases&lt;/span&gt;
    &lt;span class="s"&gt;"github.com/chromedp/cdproto/browser"&lt;/span&gt;
    &lt;span class="s"&gt;"github.com/chromedp/cdproto/dom"&lt;/span&gt;
    &lt;span class="s"&gt;"github.com/chromedp/cdproto/storage"&lt;/span&gt;
    &lt;span class="s"&gt;"github.com/chromedp/cdproto/network"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The pleasant surprises
&lt;/h2&gt;

&lt;p&gt;Well, calling them surprises is a bit of a stretch, I have been &lt;code&gt;golang&lt;/code&gt; over the years and I have to admit it is a pretty nice ecosystem and language. &lt;br&gt;
&lt;code&gt;chromedp&lt;/code&gt; automates chrome or any binary that you are able to communicate with via &lt;a href="https://github.com/chromedp/chromedp/blob/ebf842c7bc28db77d0bf4d757f5948d769d0866f/allocate.go#L349" rel="noopener noreferrer"&gt;&lt;code&gt;cdp&lt;/code&gt;&lt;/a&gt;. The API is somewhat intuitive, haven't found myself diving into the guts of it very often to figure out how stuff works. The good part is that once you extract the data from the nodes you are interested in you can map it to go structs and make use of the go typing system. &lt;/p&gt;

&lt;p&gt;For example you can grab a list of elements via selector:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;productNodes&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cdp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Node&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c"&gt;// visit the target page&lt;/span&gt;
        &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Navigate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://scrapingclub.com/exercise/list_infinite_scroll/"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;script&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WaitVisible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;".post:nth-child(60)"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Nodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`.post`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;productNodes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ByQueryAll&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Error while trying to grab product items."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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;then map each element to a struct&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;productNodes&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`h4`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ByQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FromNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
            &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`h5`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ByQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FromNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Error while trying to grab product items."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;price&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;Another nice perk is that go is built with concurrency in mind so crunching the extracted data can be a lot more performant than in puppeteer.&lt;/p&gt;

&lt;p&gt;Yet another pretty nifty thing I found is that you can deliver a binary that can be compiled for multiple platforms and can be distributed easily. This is a huge plus given that you may not really know who the user of the tool might be in the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ugly parts
&lt;/h2&gt;

&lt;p&gt;The way to communicate with the browser is still through the &lt;code&gt;cdp&lt;/code&gt; protocol and sometimes you need to pass objects only objects that can be serialized.&lt;/p&gt;

&lt;p&gt;If you need to work with objects that can't be serialized you will need to inject &lt;code&gt;js&lt;/code&gt; into the page context and interact with it.&lt;/p&gt;

&lt;p&gt;When you have a page that contains &lt;code&gt;iframes&lt;/code&gt; it is problematic to trigger events on the elements inside them. You can extract data from it but triggering events gets messy as you need &lt;code&gt;js&lt;/code&gt; for that.&lt;br&gt;
An example of how you might extract data from an &lt;code&gt;iframe&lt;/code&gt; might look something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;iframes&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cdp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Node&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Nodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`iframe`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;iframes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ByQuery&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&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="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Nodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`iframe`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;iframes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ByQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FromNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;iframes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])));&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"#second-nested-iframe"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ByQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chromedp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FromNode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;iframes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&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;But in order to trigger events on elements inside the iframe you can't just use the &lt;code&gt;chromedp&lt;/code&gt; API, and since &lt;code&gt;chromedp.Evaluate&lt;/code&gt; does not take a &lt;code&gt;Node&lt;/code&gt; as context you will need to perform all the actions in &lt;code&gt;javascript&lt;/code&gt; and that will make the resulting code a bit of a mishmash of &lt;code&gt;go&lt;/code&gt; and &lt;code&gt;js&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;puppeteer&lt;/code&gt; also has some extra packages that can be used like &lt;code&gt;puppeteer-stealth&lt;/code&gt; but &lt;code&gt;chromedp&lt;/code&gt; does not seem to have an equivalent for that at this time. The &lt;code&gt;rod&lt;/code&gt; package has &lt;a href="https://github.com/go-rod/stealth" rel="noopener noreferrer"&gt;&lt;code&gt;rod stealth&lt;/code&gt;&lt;/a&gt; but I haven't tried it since the API is not to my liking.&lt;/p&gt;

&lt;p&gt;The other slightly dissappointing missing feature is that when running in headless mode all the GPU features are disabled because it is running in a &lt;a href="https://github.com/chromedp/docker-headless-shell" rel="noopener noreferrer"&gt;&lt;code&gt;headless-chrome&lt;/code&gt;&lt;/a&gt; container which does not have a display server. Puppeteer is able to run with GPU features enabled allowing it to pass the &lt;a href="http://bot.sannysoft.com/" rel="noopener noreferrer"&gt;&lt;code&gt;webgl fingerprinting&lt;/code&gt;&lt;/a&gt; tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;in some ways puppeteer is still better than &lt;code&gt;chromedp&lt;/code&gt;, working with &lt;code&gt;iframes&lt;/code&gt; falls short&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rod&lt;/code&gt; is a nice alternative but its API looks like it was designed for testing, reminds me of &lt;code&gt;cucumber&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;chromedp&lt;/code&gt; is a nice alternative to &lt;code&gt;puppeteer&lt;/code&gt; if you are looking to build a binary that can be distributed easily &lt;/li&gt;
&lt;li&gt;it is a bit more performant than &lt;code&gt;puppeteer&lt;/code&gt; due to the concurrency model in &lt;code&gt;go&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>go</category>
      <category>scraping</category>
      <category>chrome</category>
      <category>automation</category>
    </item>
    <item>
      <title>Keep your estimates boring</title>
      <dc:creator>Artur Daschevici</dc:creator>
      <pubDate>Wed, 29 May 2024 09:12:00 +0000</pubDate>
      <link>https://dev.to/adaschevici/making-your-estimates-boring-33ki</link>
      <guid>https://dev.to/adaschevici/making-your-estimates-boring-33ki</guid>
      <description>&lt;h2&gt;
  
  
  Why?
&lt;/h2&gt;

&lt;p&gt;Most everyone I know has got shiny object syndrome. We all want to work on the latest and greatest. I myself am part of that crowd very much. Whenever I start on a project I will never pin the versions for the libraries. That adds anywhere between 0% and 50% on top of the project timeline. The most notable example is Javascript with its myriad of libraries. You would think everyone is familiar with this meme by now 😅.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe3roly01iyhuzumy5ssx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe3roly01iyhuzumy5ssx.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the first article in the series we talked about getting &lt;em&gt;anchors&lt;/em&gt; right and trying to stay away from the uniqueness bias when choosing a good anchor. In &lt;em&gt;How Big Things Get Done&lt;/em&gt; the authors also bring in a concept that was new to me: &lt;strong&gt;the reference class&lt;/strong&gt;.&lt;br&gt;
The phrase was originally coined in the 1970s by the psychologist &lt;a href="https://www.newyorker.com/books/page-turner/the-two-friends-who-changed-how-we-think-about-how-we-think" rel="noopener noreferrer"&gt;Daniel Kahneman and his colleague Amos Tversky&lt;/a&gt; and is regularly used in the context of &lt;em&gt;reference class forecasting&lt;/em&gt;.&lt;br&gt;
Daniel and Amos refer to two types of views when estimating a project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;inside view&lt;/em&gt; which is the view while working on the project with your personal biases&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;outside view&lt;/em&gt; which is the view from the outside, looking at the project as a whole and comparing it to similar projects&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Enough with the theory, in practice, what we actually want to achieve is to have our estimates work and be as accurate as possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  How?
&lt;/h2&gt;

&lt;p&gt;Now that we have the lingo down, let's get into the nitty-gritty. We want to figure out how to calculate the estimates, you got that right &lt;strong&gt;calculate&lt;/strong&gt;. The calculations are based on being able to cut down your project from being a special and unique snowflake to a project that is similar to others. It's a combination of statistical and historical analysis of other projects as similar as possible to yours.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Going on a tangent here, wouldn't it be cool if we could have a database of software project estimates with numerical data, situational requirements and conditions, and perhaps how long the project took in the end?&lt;/em&gt; 🤔&lt;/p&gt;




&lt;p&gt;You want to reduce your project to something as generic as possible then look for data about other projects like it. As a software developer you may be tempted to think that it is special and unique, but finding the commonalities will help you get a better estimate.&lt;br&gt;
We could make use of both &lt;em&gt;inside view&lt;/em&gt; and &lt;em&gt;outside view&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;outside view&lt;/em&gt;: see how long similar projects took(take the median) - I am referring to the reduced version where you cut out any product differentiators&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;inside view&lt;/em&gt;: see how long the differentiators will take (&lt;em&gt;this you can break down further as well into common tasks and unique tasks&lt;/em&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;... do you see where I am going with this? It's all turtles all the way down 🐢. Now you can already see how things can be broken down further into smaller pieces and how similarities can make estimating easier.&lt;/p&gt;

&lt;p&gt;The numbers show a 30% increase in accuracy when using &lt;em&gt;reference class forecasting&lt;/em&gt;, that is the &lt;em&gt;outside view&lt;/em&gt;, with 50% not being uncommon.&lt;/p&gt;

&lt;p&gt;The aspect that is different from plain anchor-based estimates is that you choose an anchor that is based on the &lt;em&gt;reference class&lt;/em&gt; which makes it closer to the objective reality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Into the future with AI
&lt;/h2&gt;

&lt;p&gt;Last couple of years I have been working in the field of AI and I have been an avid reader of various papers and consumed a decent amount of tutorials and courses. Deep learning is an amazingly powerful tool that is able to draw conclusions based on the importance of a particular feature of the project and classify it.&lt;/p&gt;

&lt;p&gt;If we had the data about projects we could train a multi-class classifier to predict the time it would take to complete a project(S/M/L/XL). This could be a great Trello plugin for example.&lt;/p&gt;

&lt;p&gt;Linear regression can be another simpler approach to do a numeric estimate of the project timeline. Now, this feels like we are taking all the joy out of the agile SDLC, but remember this is only supposed to be used as a data focused approach, from the &lt;em&gt;outside view&lt;/em&gt; i.e. looking objectively at the data, so no hard feelings to be had 😉.&lt;/p&gt;

&lt;p&gt;Thinking a bit further we could have an LLM + RAG system that looks at the database of projects we have broken down, does a similarity search and gives us some kind of standard estimates.&lt;/p&gt;

&lt;p&gt;The data would probably be a huge challenge for this one. You would have to get data from various sources and have it clean, usable and can be used to train the models.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stay boring&lt;/strong&gt;: don't get caught up in the thinking your project is special and unique&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the reference class&lt;/strong&gt;: look at similar projects and see how long they took&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use both views&lt;/strong&gt;: &lt;em&gt;inside view&lt;/em&gt; and &lt;em&gt;outside view&lt;/em&gt; to get a better estimate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use data&lt;/strong&gt;: if you have it, use it to your advantage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask for help&lt;/strong&gt;: if you are not sure, ask someone who has done it before, outside perspective can add a layer of objectivity&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>agile</category>
      <category>ai</category>
      <category>estimates</category>
    </item>
  </channel>
</rss>
