Skip to content

feat(app): the app scans on its own, WebView included (phase 16) - #20

Merged
hami9 merged 3 commits into
mainfrom
feat/phase-16-app-scans
Aug 29, 2026
Merged

feat(app): the app scans on its own, WebView included (phase 16)#20
hami9 merged 3 commits into
mainfrom
feat/phase-16-app-scans

Conversation

@hami9

@hami9 hami9 commented Aug 26, 2026

Copy link
Copy Markdown
Owner

What and why

Phase 16 complete. The app fetches through Rust with no CORS, runs core's pipeline, renders client-rendered shops in an embedded WebView, and writes a CSV straight to disk. No browser involved at any point.

Earlier commits on this branch landed HTTP, the scan wiring and the save dialog, and those were verified against a real shop. This one adds the WebView — the failure the CLI has always had, where a JS-built shop reads as zero products.

Two capabilities, not one. rendered_html is what this phase needs. evaluate is what phase 27 needs to read Core Web Vitals out of the Performance API, and it is written now because adding it later means reopening the window plumbing rather than writing one more command.

The render windows get none of Tauri's IPC. They load somebody else's page, and a scanned shop that could reach invoke would have the filesystem and HTTP client of this process. Only http(s) URLs are rendered — a file: URL arriving from a scanned page's own markup would point the renderer at the local disk.

Five bugs, and every one of them was ours

Worth reading, because each presented identically as "this site does not render" and they are recorded in packages/app/README.md so the next session does not rediscover them:

  1. wry drops the eval callback while a page is loading. webkitgtk/mod.rs pushes the script onto pending_scripts and returns Ok(()) without ever calling back. So an unanswered eval means not loaded yet, never broken — treating it as failure returned the pre-JS shell every single time.
  2. A window that is never mapped never starts its webview. visible(false) is the obvious choice and silently prevents the page loading at all.
  3. A time budget has to measure time. Adding a poll interval per iteration counts turns; an iteration waiting on an unanswered eval takes far longer, so a "15 second" ceiling kept a window on screen for minutes.
  4. A cheap probe and a megabyte payload cannot share a timeout. Two seconds is right for outerHTML.length and made the real read come back empty — which reads exactly like a page that rendered nothing.
  5. Loading and settling need separate budgets. Sharing one meant a slow first load spent the whole allowance before a single useful measurement: the same page returned 415 KB when it loaded fast and nothing when it did not.

The fallback is gated on zero products, not "fewer than expected". Rendering costs a second page load plus every subresource — real load on someone's server (§10) — and a partial result is a real result. An empty one is the only case with nothing to lose.

How it was verified

npm run check passes, 835 tests. cargo fmt, clippy -D warnings and cargo test clean.

Against live Persian storefronts, not fixtures:

Shop Result
kgkala.ir (WooCommerce) 43 CSV rows via Layer A, every variation, §7.6 values matching
digikala.com (Next.js) rendered 415 KB, 379 products
technolife.com rendered 1.25 MB, zero products
zitu.ir rendered 414 KB, 2 products

The finding that matters most

Rendering is not extraction. technolife renders its entire grid and yields nothing, because the rendered page carries ld+json=2 (Organization and Breadcrumb), itemtype=0 and schema.org/Product=0. Layer B is behaving correctly — there is no structured data to read.

So the WebView fixes "the HTML was empty". It does not fix "this shop publishes no structured data" — that is Layer C's job (phase 19), and the two are meant to compose: render first, extract from the rendered DOM. phase-17.md carries that into the next session.

This also adds the logging that made any of the above diagnosable. A failing Layer A used to look identical to a page with no markup; §11 says a failure should be able to explain itself, and it could not.

Checklist

  • The commits follow Conventional Commits
  • npm run check passes (format, lint, typecheck, tests)
  • New behaviour has tests
  • No currency unit is inferred, and no field is invented to fill a blank

🤖 Generated with Claude Code

hami9 and others added 3 commits August 25, 2026 13:51
A half phase, numbered like one. It adds no capability — it decides what
the app looks like once there is something real to look at. Slotting it
between 16 and 17 rather than appending it leaves every number below
untouched, which matters because phase numbers are cited from commits,
prompt files and phases.json.

It comes after 16 rather than before, deliberately. Designing against a
fixture is designing against invented content; once the app scans for
real there are real screens — a long scan reporting progress, eighty
products, an empty result, an error — and those are what a design has to
hold.

Records what a redesign may not drop, because both are §18 requirements
rather than taste: the currency confirmation keeps its own visual weight
and never becomes a skippable checkbox, and directional rules stay
logical properties or the Persian layout silently stops being right.

The Android design pass is noted as a separate half phase after 18 and
deliberately left unspecified — the phone is a different form factor and
should get its own decisions rather than a shrunk desktop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 16, most of it. The app fetches through Rust, runs `core`'s
pipeline, and writes a file — no browser involved at any point.

Verified against a real Persian WooCommerce shop: 43 rows, three
variable parents and their variations, exported and checked against §7.
BOM present, parent price cells empty, no orphan variations, every SKU
unique, prices ASCII, and 75 variation attribute values every one of
which appears verbatim in its parent's option list (§7.6).

**`runScan` moved from the extension into `core`.** §2 calls a feature
that lands on one surface a design failure, but what made this
non-negotiable rather than merely tidy is `countCurrencyUnits`: it is
what lets a surface ask §7.8's toman/rial question before writing a
file, and a second copy of that rule is exactly how this project
produces a silent ten-times price error. The extension now imports it
and behaves identically.

**The pacing split is the subtle part.** `crawlCategory` wraps whatever
client it is handed in a polite client of its own, so it must receive
the raw one — passing an already-paced client would wrap it twice,
pacing the whole crawl at double the configured delay and
double-counting its requests. The app's own first fetch, which happens
before `runScan` exists to do it, gets its own paced wrapper so the very
first request a shop ever sees is already subject to §10.

Rust stays a transport. It moves bytes and returns status and headers;
it does not decide how fast to move them or what a 403 means. Pacing is
`core`'s, block detection is `core`'s, and there are no retries, because
a retry that pushes past a block is what §2 forbids.

Also fixes two real bugs found by running it:

**HTML entities were reaching the CSV undecoded.** An SEO plugin writes
the product name into JSON-LD with HTML escapes intact and `JSON.parse`
has no reason to undo them, so a real product exported as
`… تک سیم کارت فیزیکی &#8211; Not Active`. `cleanText` now decodes, which
is the right place because parent options and variation values both pass
through it — decoding only one of the two paths would have been worse
than decoding neither.

**Scan did nothing.** The button's disabled state was set when it was
built, while the field was empty, and typing never told it otherwise. It
is updated from the input handler now rather than by re-rendering, which
would rebuild the field and take the caret with it.

Settings gains the real `Proc123Config` — exporter, content mode, page
limit, politeness, default unit — with the two warnings that matter said
out loud: shortening the delay puts more load on somebody else's server,
and copied descriptions are the shop's content. The default unit is
explicitly not an answer to §7.8; every export still asks. About states
§15's no-account promise and §2's hard constraint where a user can read
them rather than only in a repository.

835 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 16's last piece, and the failure the CLI has always had: a shop
that builds its grid in JavaScript ships an empty shell to anything that
only reads HTML, and reads as zero products. The app now renders the page
in a real WebView and reads it back.

Two capabilities, not one. `rendered_html` is what this phase needs;
`evaluate` is what phase 27 needs to read Core Web Vitals out of the
Performance API, and it is written now because adding it later means
reopening the window plumbing rather than writing one more command.

The render windows get none of Tauri's IPC. They load somebody else's
page, and a scanned shop that could reach `invoke` would have the
filesystem and HTTP client of this process. Only http(s) URLs are
rendered — a `file:` URL arriving from a scanned page's own markup would
point the renderer at the local disk.

**Five bugs, every one of them ours rather than the platform's**, and
each presented as "this site does not render":

- wry queues a script while the page is loading and drops the callback
  without calling it, so an unanswered `eval` means *not loaded yet* and
  never *broken*. Treating it as failure returned the pre-JS shell every
  single time, which is exactly what the renderer exists to avoid.
- A window that is never mapped never starts its webview, so
  `visible(false)` silently prevented the page from loading at all.
- A time budget has to measure time. Adding a poll interval per iteration
  counts turns, and an iteration waiting on an unanswered eval takes far
  longer — a "15 second" ceiling kept a window on screen for minutes.
- A cheap probe and a megabyte payload cannot share a timeout. Two
  seconds is right for `outerHTML.length` and made the real read come
  back empty, which reads exactly like a page that rendered nothing.
- Loading and settling need separate budgets. Sharing one meant a slow
  first load spent the whole allowance before a single useful
  measurement: the same page returned 415KB when it loaded fast and
  nothing when it did not.

The fallback is gated on *zero* products rather than "fewer than
expected". Rendering costs a second page load plus every subresource,
which is real load on somebody's server (§10), and a partial result is a
real result — an empty one is the only case with nothing to lose.

Verified on live Persian shops: kgkala 43 rows via Layer A, digikala
rendered 415KB and 379 products, technolife rendered 1.25MB, zitu 414KB.

**Rendering is not extraction**, and that is the finding to carry
forward. technolife renders its whole grid and yields nothing, because
the rendered page carries ld+json=2 (Organization and Breadcrumb),
itemtype=0 and schema.org/Product=0. Layer B is correct — there is no
structured data there. Those shops need Layer C over the *rendered* DOM,
which is phase 19 and which this phase makes possible without delivering.

Also adds the logging that made any of this diagnosable. A Layer A
failure used to look identical to a page with no markup; §11 says a
failure should be able to explain itself.

Phase 16 closes; 17 becomes next. phase-17.md written, and the five bugs
recorded in packages/app/README.md so they are not rediscovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hami9 hami9 closed this Aug 26, 2026
@hami9 hami9 reopened this Aug 26, 2026
@hami9
hami9 merged commit 7eba0a3 into main Aug 29, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant