My Scraper Kept Catching My Own Prompt Echo
An accessibility snapshot caught my own prompt instead of the AI answer. The fix: echo rejection, clipboard-first extraction, and a recover subcommand.
TL;DR
Scraper sempat salah tangkap karena anchor teks ikut muncul di bubble prompt sendiri, bukan di jawaban model. Perbaikannya: tolak kandidat berisi marker template dan pindahkan ekstraksi ke clipboard lewat tombol Copy, sebab jawaban panjang ternyata ter-virtualisasi di DOM. Dengan sidecar URL yang disimpan sejak awal, jawaban yang sudah jadi bisa dipulihkan tanpa kirim ulang prompt.
My poll said the answer was done. I captured the accessibility snapshot, searched for the anchor "1. Judul (H1)", and cut to the sentinel. But what came out was my own prompt echoed back by the chat page, not the article. On screen the answer was there, intact. The scraper had caught my own bubble.
The echo trap
My first guess was that my anchor regex had drifted. The real answer was more embarrassing: the anchor was on the page, all right, but inside my own prompt bubble, because my prompt template included a sample article structure. The old capture function in tools/qwen-article.py took the last occurrence of the anchor, and when the model offered two versions while asking a clarifying question back, that "last" occurrence could land inside the echoed prompt. Candidates that were too short slipped through as garbage too.
The first fix was boring on purpose. Iterate anchor matches from the last one backwards, reject any candidate containing markers from my own template such as "KONTEKS TOPIK" or "bahan riset internal", and require at least 500 characters. If every candidate gets rejected, the capture counts as failed and polling continues. A text-structure-only scraper cannot tell an answer from an echo; the markers break the tie.
The clipboard-first fix
The deeper problem is rendering. Long answers in the Qwen chat are virtualized: the accessibility snapshot and innerText only contain the visible slice. I wrote before about the virtualization side; this commit moved the extraction to a different path entirely. Playwright clicks the answer window's Copy button, then the script reads the text back with navigator.clipboard.readText() [1].
The contract here is precise. readText() returns a Promise that fulfills with a copy of the system clipboard's textual contents [1]. An empty clipboard or non-text content yields an empty string rather than an error [1], and denied access throws NotAllowedError [1]. The security requirements are strict as well: secure contexts only, and the spec expects the user to have recently interacted with the page [2]. In practice browsers grant reads when the clipboard-read permission is already given or through a per-operation prompt [2], and clicking Copy is exactly the interaction that satisfies the requirement, so the click-then-read pattern is both legitimate and reliable.
Playwright covers the clicking side. Before acting it waits for the element to pass its checks: visible, stable, not obscured, enabled [4]. Stable is concrete: the element's bounding box stays the same for two consecutive animation frames [4]. Locators are strict too; operations throw when more than one element matches [3], so clicking the wrong bubble's copy button surfaces as an error instead of silently wrong data.
In the script, the poll order flipped: clipboard first, snapshot as fallback. What lands in the output is raw markdown without virtualized gutter line numbers, and it passes the 300-character minimum validation.
Recovery beats resending
The failure that stung the most: a long answer finished generating, but the script died before pulling it. Resending the prompt means paying the free quota twice for an answer that already exists.
The fix is cheap. As soon as the prompt is sent and the chat URL is known, the script writes a .url sidecar file before polling starts. When generate runs again and that sidecar exists, nothing is re-sent; the script opens the old chat and pulls the finished answer through the same clipboard path, via a recover subcommand with a 240-second default timeout. Tab cleanup moved into a finally block, so a crashed run no longer strands the tab.
That is the pattern I now apply to every extraction from chat UIs: the DOM is a presentation layer, the clipboard is a data path. Pull stable data from the data path, not the render, and build the recovery path in from the start.
---
Sources:
- MDN — Clipboard: readText() method [1]
- MDN — Clipboard API [2]
- Playwright — Locators [3]
- Playwright — Auto-waiting [4]
[1] https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/readText [2] https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API [3] https://playwright.dev/docs/locators [4] https://playwright.dev/docs/actionability