Back to engineering

Engineering

Parsing what someone else's AI wrote

The Bookatu engineering team8 min read

The blog editor can now hand a business owner a prompt for ChatGPT, Claude, Gemini or any other AI, and read back whatever it replies. We do not control that model or its output. Here is how we ask for JSON, read it tolerantly, strip HTML until nothing changes, stay fast on hostile input, and the two review findings that changed the panel.

The short version

  • "Use your own AI" builds a prompt that is itself a JSON document, asking for a JSON object with six known keys. The owner pastes it into any AI and pastes the reply back. It all runs in the browser and uses no AI credits.
  • parseBlogAnswer reads raw JSON, JSON inside a code fence, or JSON with chatter around it, and unwraps one level of {"post": {...}}. A title and a body are required. Everything else may be missing.
  • HTML is stripped in passes until a pass changes nothing, because one pass over <<b>img ...> leaves an <img> behind. An unclosed <!-- drops only itself, not the rest of the post.
  • Every scan is bounded, so a hostile paste cannot freeze the tab. Seven tests time the worst inputs we know of.
  • Review findings fixed: Enter in the panel's Title box submitted the editor form, which on a live post meant Unpublish, and some pastes could freeze the tab. The "Replace what is there?" question is now announced to screen readers too.

Businesses on Bookatu have their own blog. The editor already had "Write it for me" buttons backed by Gemini. Some owners would rather use an AI they already pay for and know. So beside those buttons there is now a panel: give a title and a short brief, copy a prompt, paste it into your AI, paste its answer back, and the form fills in.

The interesting part is the last step. We do not choose the model, its version, its settings or its mood. The text that comes back may be clean JSON, JSON in a fence, JSON inside two paragraphs of friendly chatter, half an answer, or something the owner copied from the wrong tab. Whatever it is, the editor must either fill in sensibly or change nothing.

A prompt that is JSON

buildBlogPrompt returns a pretty-printed JSON document rather than a paragraph of instructions. It has a task, the business's public details, the brief, a list of rules, the exact limits, and an output_format showing the object we want back:

ts
const prompt = {
  task:
    "Write one blog post for this business's own website blog. The readers are the business's clients and people thinking of booking with it. Reply with ONLY the JSON object described in output_format: no other text, no code fence.",
  business,
  post_brief,
  rules: [
    // ...
  ],
  limits: {
    // ...
  },
  output_format: {
    title: "The post title",
    excerpt: "One or two sentences on what the post covers",
    body: "The full post in markdown",
    seoTitle: "Search title",
    seoDescription: "Search description",
    tags: ["topic", "another topic"],
  },
  // ...
};
return JSON.stringify(prompt, null, 2);

An example shaped like the answer is harder to misread than a paragraph describing it, and a JSON prompt is easy to test. The unit tests parse it and check its keys. Only public details go in: the business name, its type, its city and its site's language. No client data, ever. The owner's brief is capped at 1,000 characters.

We ask for no code fence. We also assume we will get one anyway.

Finding the object

findObject tries three things in order: the whole text as JSON, then the inside of each ``` fence, then the first balanced {...} that parses:

ts
function findObject(text: string): Record<string, unknown> | null {
  const whole = tryParseObject(text.trim());
  if (whole) return whole;

  // Between the 1st and 2nd ```, the 3rd and 4th, and so on; an unclosed last
  // fence holds nothing. Split, not a regex, so a fence never re-scans the text.
  const parts = text.split("```");
  for (let k = 1; k < parts.length - 1; k += 2) {
    const inner = tryParseObject(parts[k].replace(/^[a-zA-Z0-9_-]*[^\S\n]*\n?/, "").trim());
    if (inner) return inner;
  }

  let starts = 0;
  for (let i = text.indexOf("{"); i !== -1 && starts < MAX_OBJECT_STARTS; i = text.indexOf("{", i + 1), starts++) {
    const end = matchBrace(text, i);
    if (end === -1) continue;
    const obj = tryParseObject(text.slice(i, end + 1));
    if (obj) return obj;
  }
  return null;
}

matchBrace tracks string state and escapes, so a brace inside the post's own text does not end the object early. The final parse is always JSON.parse, never eval. Some models wrap the answer once, as {"post": {...}}, so a single level is unwrapped when the only nested object looks like ours.

A partial answer is handled by what we require. The title must be 2 to 140 characters and the body must be non-empty, or the owner gets a plain sentence saying which one is missing and what to ask the AI. The excerpt, SEO fields and tags may be missing and come back empty. A reply cut off in the middle of the JSON has no balanced object, so it reads as "that does not look like the AI's answer" and changes nothing.

Each field is then held to the editor's own limits. Over-long text is cut at a word boundary, the body at 50,000 characters, and tags are lowercased, de-duplicated, capped at six and at 24 characters each, the same rule the editor applies when you type them.

Why one pass of stripping is not enough

We ask for markdown with no raw HTML. Some answers include HTML anyway. The post renderer does not need our help here: post bodies are rendered by an allow-list markdown renderer that never outputs raw HTML, and that is the real boundary. The strip is for the owner. What lands in the editor should be the text they will publish, without markup they cannot see the effect of.

The obvious strip is one regex that removes everything shaped like a tag. Run it once over this:

text
x <<b>img src=x onerror=alert(1)> y

It removes <b>, and what is left is <img src=x onerror=alert(1)>, a perfectly good tag that the one pass has just built. Nesting works at any depth, so we sweep until a pass changes nothing, up to eight passes, and then disarm any "<" still starting a tag:

ts
function stripHtml(s: string, multiline: boolean): string {
  let out = s;
  for (let pass = 0; pass < MAX_STRIP_PASSES; pass++) {
    const next = stripOnce(out, multiline);
    if (next === out) break;
    out = next;
  }
  out = out.replace(/[<\/]+/g, (run: string, at: number, all: string) =>
    run.includes("<") && /[a-zA-Z!?]/.test(all.charAt(at + run.length)) ? run.replace(/</g, "") : run,
  );
  return multiline ? trimLineEnds(out).replace(/\n{3,}/g, "\n\n") : out;
}

The final replace covers a tag nested deeper than eight levels and an opening tag that is never closed, such as "<img src=x onerror=alert(1)" at the very end of a field, which no tag-shaped pattern matches. Each pass also removes script, style, iframe, svg and similar elements together with their content, when they close.

Comments needed their own care. A lazy /<!--[\s\S]*?-->/ is correct for closed comments. The trouble is an unclosed one. A post about writing HTML might contain "<!--" as text, and a strip that treats it as the start of a comment that runs to the end will silently delete the rest of the post. dropComments only removes comments that can close, and an unclosed "<!--" loses only itself:

ts
function dropComments(s: string): string {
  const last = s.lastIndexOf("-->");
  const head = last === -1 ? "" : s.slice(0, last + 3).replace(/<!--[\s\S]*?-->/g, "");
  return (head + s.slice(last === -1 ? 0 : last + 3)).replace(/<!--/g, "");
}

The tests feed the parser nested tags in the title, the excerpt, the tags and the body, an opening tag that never closes, and an unclosed comment in the middle of a paragraph. For the nested case they assert that nothing tag-shaped is left anywhere in the result and that the body reads "x y z" with the alert gone.

A paste must never freeze the tab

The parser runs in the browser, on the owner's click. Any scan that restarts from every position is quadratic, and on a large enough paste that is a tab that stops responding. Review found inputs that did exactly that: a brace scan restarted from every "{", a fence regex, a search for each risky element's closing tag from every opener, and /[ \t]+\n/ over a long run of spaces. The test file says so above the tests that pin the fixes:

ts
// Each of these used to take seconds (minutes near the input cap) because a
// scan or pattern restarted from every position. They must stay quick.
const quick = (label: string, input: () => string) =>
  it(`answers quickly on ${label}`, () => {
    // ...
    expect(ms, `${Math.round(ms)} ms for ${text.length} characters`).toBeLessThan(1500);
  });
quick("many unclosed {", () => "{".repeat(50_000));
quick("deep {{{...}}} that is not JSON", () => "{".repeat(30_000) + "x" + "}".repeat(30_000));
quick("an unclosed ``` fence with a long tag", () => "```" + "a".repeat(150_000));

The fixes are each small. The brace scan tries at most 100 starting points. That is a trade-off we accepted: an answer whose object opens after a hundred stray braces of chatter is not found, and the owner sees the plain "that does not look like the AI's answer" message instead. Fences are found with split rather than a regex. Risky elements look up each name's next closing tag once and reuse it, instead of searching again from every opener. Trailing spaces are trimmed with a loop over lines. The whole input is capped at 400,000 characters before any of it runs.

Review finding: Enter unpublished the post

The panel sits inside the editor's form. Its Title box is an ordinary text input. In an HTML form, pressing Enter in a text input submits the form through its first submit button, and the editor's first submit button depends on the post. On a draft it is Save draft. On a published post it is Unpublish.

So an owner on a live post who opened the panel, typed a title and pressed Enter to get their prompt would take the post off their site. The fix is a key handler that turns Enter into "make the prompt":

tsx
onKeyDown={(e) => {
  // This box sits inside the editor's form: Enter would submit it
  // through its first button (Unpublish, or Save draft).
  if (e.key === "Enter" && !e.nativeEvent.isComposing) {
    e.preventDefault();
    generate();
  }
}}

The isComposing check lets someone typing in Japanese or Chinese confirm a word with Enter without generating a prompt halfway through. The test had to be honest about what a browser does. A synthetic keydown on its own submits nothing in a test DOM, so a test that only fired the key would prove nothing. The helper does what a browser does: if the key event was not cancelled, it calls requestSubmit with the form's first submit button. Two tests then check that no save action ran, for a published post and for a new one, and that the prompt appeared.

One more fix landed with it. The "Replace what is there?" question, shown when an answer would overwrite words already in the post, used to appear silently. It is now a role="alert" and still labels the group holding the Yes and Keep buttons, and a test checks both.

Never saves by itself

Filling the form never saves anything. The owner reviews what came back and saves with the editor's own buttons. Words already in the post are only replaced after the confirm step, and an existing post's link never changes. An answer that cannot be read shows one plain sentence and leaves every field as it was.

What we take from it

  • When you do not control the model, ask for structure and parse for anything. Ask for no code fence, then accept one.
  • Repeat a strip until it is stable, then disarm whatever is left. A single pass can build the very tag it was meant to remove.
  • Decide what an unclosed construct means. For a comment, "runs to the end" deletes the owner's words.
  • Anything that runs on paste in the browser needs a bound on every scan, and a timing test with the worst input you can think of.
  • A text box inside a form has a submit button whether you meant it to or not. Test Enter the way a browser handles it.
aiparsingsecurityreactengineering

Building on Bookatu?

Bookatu has a public REST API and webhooks. Have a look at the developer docs.

Developer docs