Turn Web Pages into Audio with Polly
Build a serverless function that converts any web page into an MP3 audio file.
Introduction
30 Second Summary
You find an interesting article online, but you don't have time to sit and read it. What if you could turn that web page into an audio file and listen to it on the go?
In this project, you will build a serverless web page reader using AWS Lambda and Amazon Polly. You give it any web page URL, and it converts the text into natural-sounding speech and hands you back a playable MP3 link.
What You'll Build
Paste a URL into your endpoint, wait a few seconds, and get back a link to a lifelike audio version of that page ready to play in any browser.
By the end of this project, you'll have:
- A live HTTP endpoint where you paste any web page URL and get back a playable MP3 audio file.
- Text chunking that handles pages of any length by splitting text at sentence boundaries to stay within Polly's character limits.
- Secure audio storage in Amazon S3 with time-limited presigned URLs, so your files stay private until you share the link.
- Secret Mission: Add voice selection and a built-in HTML audio player so you can listen right in the browser.
Do I need any tools installed?
All code is written directly in the AWS Lambda console editor. The only tool you need on your computer is Terminal, which comes built into macOS.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Your AWS Resources
Before writing any code, you need three AWS services working together. Amazon S3 stores your audio files. AWS Lambda runs your code. Amazon Polly converts text to speech.
In this step, you will create each resource and wire them together with the right permissions. By the end, you will have a live URL that responds when you visit it in a browser.
In this step, get ready to:
- Create an S3 bucket to store generated audio files.
- Create a Lambda function with a public Function URL.
- Grant the Lambda function permissions to use Polly and S3.
Create an S3 bucket
Your Lambda function will generate MP3 audio files. Those files need a home, and that home is an S3 bucket.
- Sign in to the AWS Management Console at console.aws.amazon.com.
- In the search bar at the top, type S3 and select S3 from the results.
- Click Create bucket.
- In the Bucket name field, enter a unique name like web-reader-audio- followed by your initials (for example, web-reader-audio-jd).
- Leave all other settings at their defaults. Make sure Block all public access is checked ON.
- Scroll down and click Create bucket.
You should see your new bucket in the S3 bucket list.
Why keep Block all public access on?
Your audio files do not need to be publicly accessible. Instead of opening the bucket to the world, you will use presigned URLs later. A presigned URL grants temporary, time-limited access to a specific file without making the entire bucket public.
Seeing a bucket name error?
S3 bucket names are globally unique across all AWS accounts. If the name you chose is taken, try adding extra characters to make it unique (for example, web-reader-audio-jd-2026).
help me fix my S3 bucket name error
Create a Lambda function with a Function URL
Lambda is where your code will run. You also need a public URL so you can call your function from a browser.
- In the AWS console search bar, type Lambda and select Lambda from the results.
- Click Create function.
- Select Author from scratch.
- In the Function name field, enter web-page-reader.
- For Runtime, select Python 3.13.
- Expand the Advanced settings section at the bottom of the page.
- Check the Enable function URL checkbox.
- Set Auth type to NONE.
- Click Create function.
You land on the function dashboard. You should see a Function URL link in the Function overview section at the top.
What is a Function URL?
A Function URL is a dedicated HTTP endpoint that Lambda creates for your function. It looks like https://<url-id>.lambda-url.<region>.on.aws. Anyone with the URL can call your function directly, no API Gateway required.
The default 3-second timeout is too short for fetching web pages and synthesizing speech. You need to increase it to 30 seconds.
- On the function dashboard, click the Configuration tab.
- In the left sidebar, click General configuration.
- Click Edit.
- Change the Timeout value to 0 minutes and 30 seconds.
- Click Save.
The General configuration panel now shows a timeout of 0 min 30 sec.
Don't see the Function URL?
If the Function URL is missing from the Function overview section, you may have missed the Enable function URL checkbox during creation. You can add one now by going to the Configuration tab, selecting Function URL in the left sidebar, and clicking Create function URL. Set Auth type to NONE and click Save.
help me set up my Lambda Function URL
Add Polly and S3 permissions to the Lambda role
Your Lambda function needs permission to call Amazon Polly and to read from and write to your S3 bucket. Without these permissions, every API call will be denied.
When you created the Lambda function, AWS automatically created an IAM execution role for it. You will add an inline policy to that role now.
- On the function dashboard, click the Configuration tab.
- In the left sidebar, click Permissions.
- Under Execution role, click the role name link. This opens the IAM console in a new tab.
- In the IAM console, click Add permissions, then select Create inline policy.
- In the Policy editor section, choose the JSON option.
- Delete the default JSON and paste the following policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "polly:SynthesizeSpeech",
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject"
],
"Resource": "arn:aws:s3:::[[BUCKET_NAME="web-reader-audio-your-initials"]]/*"
}
]
}
What does this policy do?
This policy has two permission blocks. The first grants polly:SynthesizeSpeech on all resources, because Polly does not support resource-level permissions for this action.
The second grants s3:PutObject and s3:GetObject scoped only to objects inside your specific bucket. This follows the principle of least privilege.
- Click Next.
- In the Policy name field, enter polly-s3-access.
- Click Create policy.
You should see polly-s3-access listed under the role's Permissions policies section.
Policy not showing up?
Make sure you clicked the execution role link from the Lambda console, not a different role. The role name starts with web-page-reader-role- followed by a random string. Double check that your JSON has no syntax errors, especially around quotes and commas.
help me fix my IAM inline policy
Everything is wired up. Before you move on, do you think the default Lambda function will respond when you visit its URL in a browser?
- Switch back to the Lambda console tab in your browser.
- In the Function overview section, click the Function URL link to open it in a new tab.
You should see "Hello from Lambda!" displayed in your browser. Your function is live and reachable from the internet.
Your infrastructure is in place. Next up, you will replace that default greeting with real code that makes your Lambda function speak using Amazon Polly.
Make Your Lambda Speak
Your Lambda function is live and your permissions are in place. Visiting the Function URL returns a plain text greeting, which proves the function runs, but it does not do anything interesting yet.
In this step, you will connect your function to Amazon Polly. Hearing it speak is the fastest proof that Lambda, Polly, and your IAM permissions are all wired together correctly.
In this step, get ready to:
- Write the Polly synthesis code in the Lambda console editor.
- Test your Function URL and hear a greeting spoken aloud.
Write the Polly synthesis code
Amazon Polly is a text-to-speech service. Your code will send a text string to Polly, receive an audio stream back, and return it as a downloadable MP3 file.
- Return to the web-page-reader function in the Lambda console.
- Click the Code tab to open the code editor.
- Select all the existing code in lambda_function.py and replace it with the following:
import boto3
import base64
# Create a Polly client outside the handler for reuse
polly = boto3.client('polly')
def lambda_handler(event, context):
# Convert text to speech using Polly's Neural engine
response = polly.synthesize_speech(
Engine='neural',
OutputFormat='mp3',
VoiceId='Joanna',
Text='Hello! I am your web page reader, powered by Amazon Polly.'
)
# Read the audio stream and encode it for the HTTP response
audio_data = response['AudioStream'].read()
return {
'statusCode': 200,
'headers': {'Content-Type': 'audio/mpeg'},
'body': base64.b64encode(audio_data).decode('utf-8'),
'isBase64Encoded': True
}
What does this code do?
polly = boto3.client('polly') creates an Amazon Polly client outside the handler function. This means it gets reused across invocations instead of being recreated every time the function runs.
synthesize_speech() sends your text to Amazon Polly and asks for an MP3 back. The neural engine produces the most natural-sounding voices available.
VoiceId='Joanna' selects a specific Neural voice. Joanna is a US English voice.
The audio stream is base64-encoded because Lambda Function URLs need binary data encoded this way. Setting isBase64Encoded to True tells Lambda to decode it back to raw audio before sending it to the browser.
- Click Deploy to save your function.
- You should see a success notification appear at the top of the code editor confirming your changes were saved.
Don't see the Deploy button?
- Make sure you are in the Code tab of your Lambda function, not the Configuration tab.
- If Deploy appears grayed out, click inside the code editor first to activate it.
- Help me deploy my Lambda function code.
✔️ Awesome, I've got everything!
Great. Make sure you have clicked Deploy to save your changes before moving on.
ⓧ I'd like to double check the full code
Here is the complete lambda_function.py file. Compare it against your code editor:
import boto3
import base64
# Create a Polly client outside the handler for reuse
polly = boto3.client('polly')
def lambda_handler(event, context):
# Convert text to speech using Polly's Neural engine
response = polly.synthesize_speech(
Engine='neural',
OutputFormat='mp3',
VoiceId='Joanna',
Text='Hello! I am your web page reader, powered by Amazon Polly.'
)
# Read the audio stream and encode it for the HTTP response
audio_data = response['AudioStream'].read()
return {
'statusCode': 200,
'headers': {'Content-Type': 'audio/mpeg'},
'body': base64.b64encode(audio_data).decode('utf-8'),
'isBase64Encoded': True
}
Test your speaking Lambda
Your function is deployed and wired to Polly. Visiting the Function URL triggers the function, which calls Polly and returns the audio.
Before you test, what do you think will happen when you visit your Function URL now? Will you still see the text greeting, or something different?
- In the Lambda console, look at the Function overview section at the top of the page.
- Find your Function URL displayed there and copy it.
- Open a new browser tab and paste the URL into the address bar.
- Press Enter to visit the URL.
Your browser downloads an MP3 file. Open it and you will hear the greeting spoken aloud by Joanna, a Neural voice.
Browser showing an error instead of downloading?
- Make sure you clicked Deploy after pasting the new code. An undeployed function still runs the old code.
- Check that your IAM inline policy includes polly:SynthesizeSpeech. Without this permission, Polly calls fail with an access denied error.
- If you see a timeout error, confirm that your Lambda timeout is set to 30 seconds under Configuration > General configuration.
- My Lambda Function URL is not returning an MP3 file. How do I debug this?
Your Lambda function can speak. Next, you will teach it to read real web pages and turn them into audio.
Fetch and Read a Web Page
Your Lambda function can speak. Visiting the Function URL plays a greeting from Joanna, proving that Lambda and Amazon Polly are wired together correctly.
A hardcoded greeting is not useful on its own. The real goal is reading actual web pages aloud. In this step, you will teach your function to accept any URL, fetch the page content, extract its text, and send it to Polly for speech.
In this step, get ready to:
- Build an HTML parser that extracts readable text from web pages.
- Update the Lambda handler to accept a URL parameter and convert the page to speech.
- Test with real web pages and discover what happens with longer content.
Add web page fetching and text extraction
Web pages are full of HTML tags, scripts, and navigation elements that you do not want read aloud. You need a parser that strips away all the noise and keeps only the visible text content.
- Return to the Lambda console code editor for web-page-reader.
- Find the existing import lines at the top of lambda_function.py and add these two lines directly below import base64:
import urllib.request
from html.parser import HTMLParser
- Now add the following TextExtractor class below the polly = boto3.client('polly') line:
# Extracts visible text from HTML, skipping non-content tags
class TextExtractor(HTMLParser):
def __init__(self):
super().__init__()
self.text = []
self.skip = False
def handle_starttag(self, tag, attrs):
# Flag content inside non-visible tags to skip
if tag in ('script', 'style', 'nav', 'header', 'footer'):
self.skip = True
def handle_endtag(self, tag):
if tag in ('script', 'style', 'nav', 'header', 'footer'):
self.skip = False
def handle_data(self, data):
# Collect non-empty text from visible tags only
if not self.skip:
text = data.strip()
if text:
self.text.append(text)
def get_text(self):
return ' '.join(self.text)
What does TextExtractor do?
TextExtractor extends Python's built-in HTMLParser to walk through an HTML document tag by tag.
- When it encounters a script, style, nav, header, or footer tag, it sets a skip flag so none of that content gets collected.
- For everything else, it grabs the text, strips whitespace, and stores it in a list.
- get_text() joins all the collected pieces into one clean string, ready for Polly.
- Below the TextExtractor class, add the fetch_page_text() function by pasting this code:
# Fetches a web page and returns its visible text content
def fetch_page_text(url):
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=10) as response:
html = response.read().decode('utf-8', errors='ignore')
extractor = TextExtractor()
extractor.feed(html)
return extractor.get_text()
What does fetch_page_text do?
- It creates an HTTP request with a User-Agent header so web servers treat it like a regular browser visit instead of blocking it.
- It downloads the raw HTML, decodes it to text, then feeds it through TextExtractor to strip out all the tags and return only readable content.
Accept a URL parameter and synthesize the page text
The helper functions are ready. Now you need to update the handler to accept a URL from the caller, fetch that page, and send the extracted text to Polly.
- Find the existing lambda_handler function in your code. Replace the entire function with this updated version:
def lambda_handler(event, context):
# Read the URL from query parameters
params = event.get('queryStringParameters') or {}
url = params.get('url')
if not url:
return {
'statusCode': 400,
'body': '{"error": "Please provide a url parameter"}'
}
try:
# Fetch the page text and convert to speech
text = fetch_page_text(url)
response = polly.synthesize_speech(
Engine='neural',
OutputFormat='mp3',
VoiceId='Joanna',
Text=text
)
audio_data = response['AudioStream'].read()
return {
'statusCode': 200,
'headers': {'Content-Type': 'audio/mpeg'},
'body': base64.b64encode(audio_data).decode('utf-8'),
'isBase64Encoded': True
}
except Exception as e:
return {
'statusCode': 500,
'body': '{"error": "' + str(e) + '"}'
}
What changed in the handler?
- Instead of a hardcoded greeting, the handler reads a url query parameter from the incoming request.
- If no URL is provided, it returns a 400 error telling the caller what to do.
- A try/except block catches any errors during fetching or synthesis and returns them as a 500 response instead of crashing silently.
✔️ Awesome, I've got everything!
Great. Double check you have four imports at the top, the TextExtractor class, the fetch_page_text() function, and the updated lambda_handler in that order.
ⓧ I'd like to double check the full code
Scroll up and confirm each of these sections is present in your lambda_function.py, in this order:
- Four import lines: import boto3, import base64, import urllib.request, from html.parser import HTMLParser.
- Polly client: polly = boto3.client('polly').
- TextExtractor class: extends HTMLParser with __init__, handle_starttag, handle_endtag, handle_data, and get_text methods.
- fetch_page_text function: fetches a URL with a User-Agent header and passes the HTML through TextExtractor.
- Updated lambda_handler: reads a url query parameter, calls fetch_page_text(), passes the text to polly.synthesize_speech(), and returns the audio as base64.
- Click Deploy in the Lambda code editor to save and deploy your updated function.
- Copy your Function URL from the Function overview section at the top of the page.
- Paste the URL into your browser and add ?url=https://example.com to the end, so it looks like this:
[[FUNCTION_URL="your Lambda Function URL"]]?url=https://example.com
Your browser downloads an MP3 file. Open it and you should hear the text content of example.com read aloud by Joanna.
Not hearing audio or seeing an error?
- Check that you clicked Deploy after making your code changes. The orange banner at the top of the editor should disappear after a successful deploy.
- Make sure your URL includes ?url=https://example.com with the full https:// prefix. Without it, the function cannot fetch the page.
- Verify that the four import lines are at the very top of the file and that TextExtractor appears before fetch_page_text(). Python reads top to bottom, so order matters.
Why can't I hear audio from my Lambda function?
Hit the character limit
The example.com page has only a few sentences of text. Real web pages like news articles and Wikipedia entries have thousands of characters.
Before you test with a longer page, what do you think will happen when Polly receives several thousand characters of text in a single request?
- Test your function with a longer page by pasting this URL into your browser:
[[FUNCTION_URL="your Lambda Function URL"]]?url=https://en.wikipedia.org/wiki/Amazon_Web_Services
Instead of downloading an MP3, your browser displays a JSON error response. The error message contains TextLengthExceededException.
Why did this fail?
Polly's SynthesizeSpeech API has a hard limit. Each request can contain a maximum of 6,000 characters total, of which no more than 3,000 can be billed characters. A Wikipedia article easily blows past that.
This is expected. Your code sent the entire page text to Polly in a single call, and Polly rejected it. The next step solves this by splitting the text into smaller chunks that each fit within the limit.
Your function can read small pages aloud, but real-world content is too big for a single Polly request. Next up, you will split text into chunks and store the audio in S3 so anyone can listen.
Handle Long Pages and Save to S3
In the last step, you hit a wall. Real web pages have thousands of characters, but Amazon Polly only accepts 3,000 billed characters per SynthesizeSpeech request.
The fix is chunking: splitting the text into smaller pieces, synthesizing each one, and stitching the audio back together. You will also store the finished MP3 in S3 so you can share a simple link instead of streaming raw audio bytes.
In this step, get ready to:
- Break text into Polly-safe chunks at sentence boundaries.
- Upload combined audio to S3 and generate a time-limited presigned URL.
- Test the full pipeline with a real web page using curl.
Update your imports and add S3 setup
Before writing the chunking logic, the top of your file needs a few changes. You will swap out base64 (no longer needed) for json and time, and create an S3 client so your function can upload files.
- In the Lambda console code editor, find the import lines at the top of lambda_function.py.
- Find this block:
import boto3
import base64
import urllib.request
from html.parser import HTMLParser
polly = boto3.client('polly')
- Replace it with the following, which adds the new imports and creates an S3 client alongside your Polly client:
import json
import boto3
import urllib.request
from html.parser import HTMLParser
import time
polly = boto3.client('polly')
s3 = boto3.client('s3')
BUCKET_NAME = '[[S3_BUCKET="web-reader-audio-your-initials"]]'
What changed here?
- json replaces base64. Instead of encoding raw audio, your function will return JSON with a download link.
- time generates unique timestamps for each audio file stored in S3.
- s3 = boto3.client('s3') is created outside the handler so it is reused across invocations, just like the Polly client.
- BUCKET_NAME points to the S3 bucket you created in Step 1.
Add the text chunking function
Polly accepts a maximum of 3,000 billed characters per request. Your chunking function will split text into pieces of 2,900 characters or fewer, breaking at sentence boundaries so words are never cut in half.
- Scroll down to just below the fetch_page_text() function and add the following chunk_text() function:
def chunk_text(text, max_chars=2900):
# Split text into pieces that fit within Polly's character limit
chunks = []
while text:
if len(text) <= max_chars:
chunks.append(text)
break
# Try to break at a sentence boundary first
split_at = text.rfind('. ', 0, max_chars)
if split_at == -1:
# Fall back to breaking at a space
split_at = text.rfind(' ', 0, max_chars)
if split_at == -1:
# Last resort: hard cut at the character limit
split_at = max_chars
else:
split_at += 1
chunks.append(text[:split_at].strip())
text = text[split_at:].strip()
return chunks
What does this code do?
The function walks through the text and carves off pieces that stay under 2,900 characters. It first looks for a sentence boundary (. ). If none exists, it splits at a space. As a last resort, it hard-cuts at the character limit.
The 2,900 limit gives a 100-character safety margin below Polly's 3,000 billed character ceiling.
- Click Deploy to save your changes.
- You should see a green banner that reads Successfully updated the function web-page-reader. This confirms there are no syntax errors in your new code.
Seeing a syntax error on deploy?
- Make sure chunk_text() is defined at the top level of the file (no extra indentation), directly below fetch_page_text().
- Check that you removed the old import base64 line and replaced it with import json and import time.
- Confirm that BUCKET_NAME is wrapped in quotes. It should be a string, not a bare variable.
Help me fix a syntax error in my Lambda function after adding chunk_text and new imports.
Synthesize audio from chunks and save to S3
With the chunking logic in place, you need a function that loops through those chunks, calls Polly for each one, and glues the audio together. Then you will rewrite the handler to upload the finished MP3 to S3 and return a shareable link.
- Add the synthesize_chunks() function directly below chunk_text():
def synthesize_chunks(chunks):
# Call Polly for each chunk and collect the audio
audio_parts = []
for chunk in chunks:
response = polly.synthesize_speech(
Engine='neural',
OutputFormat='mp3',
Text=chunk,
VoiceId='Joanna'
)
audio_parts.append(response['AudioStream'].read())
# Concatenate all MP3 audio into one continuous stream
return b''.join(audio_parts)
What does this code do?
The function sends each text chunk to Polly and reads the audio bytes from the response. After all chunks are processed, it joins the byte arrays into a single MP3 stream.
MP3 is a frame-based format where each frame is independent. Concatenating MP3 byte streams produces a valid file with no re-encoding needed.
Now replace the entire lambda_handler function. The new handler fetches the page, caps the text at 15,000 characters, chunks it, synthesizes the audio, uploads the MP3 to S3, and returns a presigned URL.
- Scroll down to the existing lambda_handler function.
- Select everything from def lambda_handler(event, context): to the end of the file and delete it.
- Replace it with the following code. The first block handles input and text processing:
def lambda_handler(event, context):
# Read the URL parameter from the request
params = event.get('queryStringParameters') or {}
url = params.get('url')
if not url:
return {
'statusCode': 400,
'body': json.dumps({'error': 'Please provide a url parameter'})
}
try:
# Fetch and clean the page text
text = fetch_page_text(url)
if not text:
return {
'statusCode': 400,
'body': json.dumps({'error': 'Could not extract text from the page'})
}
# Cap text length for reasonable processing time
text = text[:15000]
# Split into chunks and synthesize audio
chunks = chunk_text(text)
audio_data = synthesize_chunks(chunks)
- Immediately below (still inside the same function), paste the S3 upload and response code:
# Upload the combined audio to S3
file_key = f"audio/{int(time.time())}.mp3"
s3.put_object(
Bucket=BUCKET_NAME,
Key=file_key,
Body=audio_data,
ContentType='audio/mpeg'
)
# Generate a time-limited download link
presigned_url = s3.generate_presigned_url(
'get_object',
Params={'Bucket': BUCKET_NAME, 'Key': file_key},
ExpiresIn=3600
)
# Return the audio URL and metadata
return {
'statusCode': 200,
'body': json.dumps({
'message': 'Audio generated successfully',
'audio_url': presigned_url,
'text_length': len(text),
'chunks': len(chunks)
})
}
except Exception as e:
return {
'statusCode': 500,
'body': json.dumps({'error': str(e)})
}
What does this handler do?
- text = text[:15000] caps the extracted text at 15,000 characters. This keeps synthesis time within Lambda's 30-second timeout.
- chunk_text() splits the capped text into Polly-safe pieces, and synthesize_chunks() converts them all to audio.
- s3.put_object() uploads the combined MP3 to your bucket under the audio/ prefix with a timestamp-based filename.
- generate_presigned_url() creates a temporary download link that expires in 1 hour (3,600 seconds). The S3 bucket stays fully private.
What is a presigned URL?
A presigned URL is a time-limited link that grants temporary access to a private S3 object. Anyone with the link can download the file until it expires. Your bucket stays locked down with Block all public access on, but individual files become accessible through these short-lived URLs.
- Click Deploy.
- Wait for the green Successfully updated the function web-page-reader banner.
Deploy succeeded but worried about the handler?
- Check that the second code block (the S3 upload section) is indented at the same level as text = fetch_page_text(url). Both should be inside the try: block.
- Make sure BUCKET_NAME matches the exact name of the S3 bucket you created in Step 1.
- Confirm except Exception as e: is aligned with try: (one indent level in from def).
Help me fix indentation or structure issues in my Lambda handler.
Test with a real web page
The moment of truth. The same long pages that failed in Step 3 should now work because the text gets split into safe chunks.
- Copy your Function URL from the Function overview section at the top of your function's page.
Before you run the next command, think about what you expect to see. The last time you tried a long page, Polly threw a TextLengthExceededException. Do you think it will succeed this time?
- Open Terminal on your Mac by pressing Cmd+Space, typing Terminal, and pressing Enter.
- Test your function with a Wikipedia article by running this command:
curl "[[FUNCTION_URL="your Lambda Function URL"]]?url=https://en.wikipedia.org/wiki/Cloud_computing"
After a few seconds, you should see a JSON response containing an audio_url, text_length, and chunks count. The chunking worked.
- Copy the audio_url value from the JSON response (the long URL starting with https://).
- Paste it into your browser's address bar and press Enter.
You should hear the Wikipedia article read aloud by Joanna. The same page that crashed with a TextLengthExceededException now plays as smooth, continuous audio.
Getting an error from curl?
- If you see an AccessDenied error in the JSON, double-check that BUCKET_NAME in your code matches the exact name of your S3 bucket from Step 1.
- If the request times out, confirm your Lambda timeout is set to 30 seconds under Configuration > General configuration.
- If the presigned URL does not play audio, make sure the IAM inline policy grants s3:GetObject on your bucket.
Help me debug my Lambda function that is failing when I call it with curl.
✔️ Awesome, I've got everything!
Great. Double-check you have clicked Deploy and saved your latest version.
ⓧ I'd like to double check the full code
Scroll through your lambda_function.py in the Lambda console editor and confirm these sections are present, in this order:
- Import statements: json, boto3, urllib.request, HTMLParser, time.
- Client setup: polly = boto3.client('polly'), s3 = boto3.client('s3'), and BUCKET_NAME set to your bucket name.
- TextExtractor class: unchanged from Step 3, with handle_starttag, handle_endtag, handle_data, and get_text.
- fetch_page_text() function: unchanged from Step 3.
- chunk_text() function: splits text at sentence boundaries with a 2,900 character limit.
- synthesize_chunks() function: loops through chunks, calls Polly, and concatenates audio.
- lambda_handler() function: reads a url parameter, caps text at 15,000 characters, chunks it, synthesizes audio, uploads to S3, and returns JSON with a presigned URL.
Your web page reader can now handle pages of any reasonable length and delivers a clean, shareable audio link. Next up, you will take on the Secret Mission to add voice selection and a built-in browser audio player.
Secret mission
Add Voice Selection and a Browser Player
Your web page reader works, but everyone hears the same voice and has to copy-paste audio URLs. In this secret mission, add voice selection from six Neural voices and an embedded HTML audio player so you can listen right in the browser.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses serverless AWS resources that are covered by the free tier for 12 months and cost nothing when idle.
Resources you used:
- S3 bucket (web-reader-audio-<initials>) with MP3 files stored under the audio/ prefix.
- Lambda function (web-page-reader) with a public Function URL.
- IAM inline policy (polly-s3-access) attached to the Lambda execution role.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep experimenting with your Web Page Reader.
- Your Lambda function costs nothing when it is not being invoked.
- S3 charges only for stored MP3 files. The cost is fractions of a cent per GB per month and covered by the free tier for 12 months.
- Amazon Polly charges only when you call the API. Neural voices include 1 million characters per month free for 12 months.
Pause - I'll come back to this later
Serverless resources only incur costs when you actively use them. There is nothing to pause.
- Your Lambda function, S3 bucket, and Amazon Polly access all remain available but idle.
- To reduce stored file costs, you can delete the generated MP3 files while keeping the bucket itself.
- Open the S3 console, click your bucket name, select all objects under the audio/ prefix, and choose Delete. Confirm the deletion when prompted.
Delete - I don't want to use this again
Remove all project resources from your AWS account. Follow these steps in order.
- Empty your S3 bucket first. Open the S3 console, select your bucket, and click Empty. Confirm the deletion when prompted.
- Delete the S3 bucket. Select your bucket again, click Delete, and confirm when prompted.
- Delete the Lambda function. Open the Lambda console, select web-page-reader, click Actions, then Delete function. Confirm when prompted.
- Remove the IAM inline policy. In the Lambda console, go to Configuration > Permissions and click the execution role name to open it in IAM. Find the polly-s3-access policy, expand it, and choose Delete. Confirm when prompted.
Nice Work!
Nice Work!
You just built a serverless web page reader that turns any URL into lifelike audio on demand. Paste a link, get back a voice reading the page to you.
You learned how to:
- Built a serverless function with AWS Lambda that fetches a web page, extracts its text, and converts it to natural-sounding speech using Amazon Polly Neural voices.
- Solved Polly's 3,000-character limit by splitting text into chunks at sentence boundaries and combining the audio back together.
- Stored generated audio in Amazon S3 and created time-limited presigned URLs for secure access, keeping the bucket fully private while making files shareable.
- Secret Mission: Added voice selection from six Neural voices and an embedded HTML audio player so you can listen right in the browser.
Ready to quiz yourself?