Notifio's auto-reply has two halves. First you show it what to do: it opens a listing site in a window, you fill in the contact form by hand, and it writes down what you did. Later it replays that against new listings. The replay side is the subject of the post about the year 60901; this one is about the reading side.
Both halves need the same thing: given a page, list every form control on it with the best label and the most durable selector we can find. So that code exists once and both halves import it, which is what I would have told you before I looked.
It exists twice. On purpose. Deduplicating it would break both copies, and for two reasons that have nothing to do with each other.
Copy one: a sandboxed preload cannot require a local file
The recorder window loads third-party rental sites. Real ones, with their own scripts. So Electron's renderer sandbox stays on, which is not negotiable for a window whose entire job is to render somebody else's JavaScript while the user is signed in.
A sandboxed preload can require a small allow-list of modules. electron is on it. ./form-schema is not. There is no path by which that file reaches a sandboxed preload as an import.
Copy two: page.evaluate posts your function's source
The replayer runs under Playwright, and reads the live form like this:
const fields = await page.evaluate(extractFormFields);
That is a function reference being handed across a process boundary. Playwright serialises the function to a string, sends the string, and the browser evaluates it in the page. The browser has never heard of your module graph. Anything the function body references from the surrounding scope is undefined at the far end, and the failure arrives as a ReferenceError from inside a page you are not debugging.
So the constraint on the two copies is identical, and the reason for it is completely different. One is a security boundary, one is a serialisation boundary. That is written at the top of both files, because the only thing keeping two copies honest is each one knowing the other exists:
IMPORTANT: this file is deliberately self-contained. The recorder window loads
third-party rental sites, so it keeps Electron's renderer sandbox enabled, and
a sandboxed preload can only `require` a small allow-list of modules, not local
files. That is why the DOM helpers below are duplicated from form-schema.ts
rather than imported. form-schema.ts keeps the Playwright-side copy, which has
the same constraint for a different reason (page.evaluate serialises the
function source, so it cannot close over module scope either).
I am not going to pretend this is free. It is two copies of about eighty lines of DOM reading that can drift, and the only defence is a comment. The alternative is a bundler step that inlines a shared module into a sandboxed Electron preload and into a string destined for page.evaluate, so that two runtime environments with two different sets of rules both depend on a build artefact being correct. For eighty lines of getComputedStyle and closest('label'), I took the duplication. For eight hundred I would not have.
What the duplicated knowledge actually is
The interesting part is that these constraints force the knowledge inline, which means you can read all of it in one place instead of inferring it from a chain of utilities.
Visibility is two checks, not one
function visible(el: Element): boolean {
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false;
}
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
The computed style catches the deliberate cases. The bounding box catches everything else: a control inside a collapsed accordion, a field in a max-height: 0 wrapper, an input that a stylesheet has reduced to nothing. Neither check subsumes the other, and a form reader that only consults display will happily offer you three fields from a closed tab panel.
Finding a label is six attempts in confidence order
function labelFor(el: Element): string {
// 1. label[for="id"]
// 2. aria-label
// 3. aria-labelledby, resolving every id and joining them
// 4. el.closest('label')
// 5. placeholder
// 6. last resort: the nearest enclosing element's text
}
The order is the whole design, and each step is below the one above it for a specific reason.
An explicit label[for] is the author telling you the answer. aria-label is also the author telling you, but it is a string chosen for a screen reader, which occasionally means it is more verbose than the visible text. aria-labelledby is next rather than higher because it requires resolving a list of ids and joining their text, and any one of them being missing degrades the result silently.
A wrapping <label> comes fourth because its textContent includes everything inside it, which on a consent row means the label text plus the full text of the two links inside it.
placeholder is fifth because it is the first entry that is not a label at all. It is a hint, it disappears when the user types, and plenty of forms use it as the only labelling they have, which is an accessibility failure that we nonetheless have to read.
Sixth is the parent's entire text content, which is a guess. It is in there because a field with no label of any kind is common enough on real sites that returning an empty string instead would lose the field. Which is also why every one of these goes through:
function clean(text: string | null | undefined): string {
return (text ?? '').replace(/\s+/g, ' ').trim().slice(0, 120);
}
That slice(0, 120) is not tidiness. It is specifically there because step six can return a container holding a paragraph of legal text, and an unbounded label ends up in a recorded recipe on disk.
The ids we refuse to use
An id is the most tempting selector in the DOM and, on a modern site, the one most likely to be worthless:
const id = el.getAttribute('id');
if (id && !/^[0-9]|:|^radix-|^headlessui-|^mui-|^react-select-/.test(id)) {
push(`#${cssEscape(id)}`, 'id', 85);
}
Four classes of rejection:
-
^[0-9]: a CSS identifier cannot begin with a digit without escaping, and in practice an id that starts with one was generated, not authored. -
:: Radix and friends emit ids like:r0:. Perfectly legal as an attribute value, and a colon in a selector means a pseudo-class unless it is escaped. -
^radix-,^headlessui-,^mui-,^react-select-: these are stable within a page load and meaningless across one. A recipe recorded against#radix-42is a recipe that works until the next render.
The last group is the one worth internalising. A selector's job is to survive time, and a component library's generated id is the fastest-decaying thing on the page while also looking like the most precise.
Uniqueness is checked at record time, not assumed
const name = el.getAttribute('name');
if (name) {
const sel = `[name="${cssEscape(name)}"]`;
if (unique(sel)) push(sel, 'name', 90);
}
unique() is a querySelectorAll(...).length === 1 against the page as it is right now. name="email" is an excellent selector on a listing page and an ambiguous one the moment a newsletter signup appears in the footer. Checking at record time cannot stop the page changing later, but it does stop us ever writing down a selector that was already ambiguous when we saw it, which turns out to be the common case.
Nothing is recorded as a single selector
Each element is captured as a scored list, and the replayer tries them in order:
data-testid 100
[name="..."] 90
#id 85
[aria-label="..."] 78
[placeholder="..."] 72
button:has-text() 65
A redesign that changes class names and re-renders ids does not break a recipe that also knows the field's name and its aria-label. One selector is a single point of failure dressed up as precision.
And a small helper that exists because of the same constraint
function cssEscape(value: string): string {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(value);
}
return value.replace(/["\\]/g, '\\$&');
}
CSS.escape is in every browser that matters. It is not in every context this code runs in, and a typeof guard is cheaper than finding out which one is the exception at the far end of a serialised function.
One thing that is not duplicated
The recorder sends raw typed values to the main process, which is how it works out what each field means by matching them against your saved profile. Those values never touch disk:
Raw typed values are sent to main only so it can work out what each field
means by matching against the saved profile. They are never written to disk;
recipes store a masked preview.
A recipe is a shape, not a filled-in form. The consent checkbox handling has its own rules, which I wrote up separately in The consent checkbox we tick for you, and the one we never will.
What I would take from this
Two copies of a function is usually a smell. It is not a smell when the copies exist because two runtimes genuinely cannot share code, and the honest version of that situation is a comment in each file naming the other one, rather than a build step that makes the duplication invisible and the failure mode worse.
And page.evaluate(fn) is a boundary, not a convenience. The function you pass it is source code in transit. Everything it needs has to be inside it.
If you want to see the recording flow from the user's side, it is on the help page, the per-site honesty about where auto-reply does and does not work is on pages like Kamernet, and the app is on the download page.
Top comments (0)