Keep source URLs out of TTS narration: one string for copy and TXT export

Ryan Zhu · October 2, 2026 · Developer tutorial · AI-assisted writing and code

When a listening script needs references, the references should not become part of the narration. Copying a page's entire innerText into a TTS app also copies navigation, button labels and footnotes. A second problem is less obvious: a copy button and a TXT download can drift apart if they build their output separately.

I'm Ryan Zhu, the developer of 「自听」MyListen. This article and its code were prepared with AI assistance. The example was checked in a browser, including actual copy-and-paste and a downloaded TXT file. This is a text export example, not a speech engine or a claim that this particular script was tested on an iPhone.

Keep narration and sources in different fields

The data model is small enough to stay in one HTML file:

const episode = {
  title: "Which boundary did Voyager cross?",
  paragraphs: [
    "Voyager 1 reached interstellar space in 2012.",
    "Voyager 2 reached interstellar space in 2018."
  ],
  sources: [{
    label: "NASA Voyager mission page",
    url: "https://science.nasa.gov/mission/voyager/",
    use: "Mission milestones"
  }]
};

Only title and paragraphs belong in the exported script. Render sources separately so a reader can inspect the evidence without hearing a URL spoken aloud.

This structure does not verify facts automatically. During the Voyager example, a NASA FAQ still contained an older status for Voyager 2, while the mission page recorded its 2018 crossing. Even an official source needs a date and a defined scope. I used the current mission page for milestones and the FAQ for concepts, and recorded that distinction on the case page.

Serialize once, use it everywhere

function narrationText(data) {
  return [data.title, ...data.paragraphs]
    .map(value => value.replace(/\r\n?/g, "\n").trim())
    .join("\n\n") + "\n";
}
const text = narrationText(episode);

Normalizing CRLF and CR to LF makes the stored output predictable. A blank line separates paragraphs. Both the clipboard and download use the same text; a later correction cannot update one output path and miss the other.

For page rendering, create paragraph elements and set textContent. Do not interpolate untrusted paragraphs into innerHTML. This demo uses fixed data. If you extend it to accept external source URLs, validate the allowed URL schemes and destinations too; the fixed-data example is not a complete external-input security design.

A copy failure needs a usable next step

The complete HTML resolves the DOM elements below with querySelector:

async function copyScript() {
  try {
    await navigator.clipboard.writeText(text);
    status.textContent = "Script copied";
  } catch {
    fallback.hidden = false;
    textarea.value = text;
    textarea.focus();
    textarea.select();
    status.textContent = "Copy the selected text, or download TXT";
  }
}

Clipboard access depends on a secure context, a user action and browser permissions. A visible error message alone gives the user nowhere to go. A selectable textarea and a download link keep the task possible. The demo also has a separate manual-copy button, so the fallback can be inspected without manufacturing a browser permission failure.

Download the same string

function downloadScript() {
  const url = URL.createObjectURL(
    new Blob([text], { type: "text/plain;charset=utf-8" })
  );
  const link = document.createElement("a");
  link.href = url;
  link.download = "listening-script-example.txt";
  document.body.append(link);
  link.click();
  link.remove();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}

The click is triggered by the user's download button. The page does not upload text or call an AI API. Revoke the temporary object URL after the download has started.

Run the complete example and download its single-file HTML. The code and the three output paths are directly inspectable. The original Chinese source is also available as a public GitHub Gist.

Check the output, not only the success message

For this example, the text pasted into another textarea matched the manually selected text. The downloaded UTF-8 TXT matched that same string. Source notes were excluded from both outputs.

The longer Voyager case has complete English and Chinese scripts, TXT downloads and NASA source notes. Its mobile language link was checked at 375 pixels, with no horizontal page overflow.

The practical boundary matters: this example delivers text and code. It does not parse PDFs, crawl websites, automatically validate a source, or synthesize voice. Once a long script is ready, 「自听」MyListen can generate and save its audio locally on iPhone or iPad for later listening. It has no subscription and no ads; download to try, with full functionality unlocked in the app.

Run and download the complete HTML · English product homepage