Quick answer: To extract URLs from a page, parse the HTML and read every anchor tag's href attribute, then resolve each one against the page's base URL so relative paths become absolute. In Python use requests plus BeautifulSoup with urljoin; in Node use fetch plus Cheerio with new URL(href, base). For links that only appear after JavaScript runs, you need a rendered DOM or an API that renders for you.
A URL extractor sounds trivial until you actually build one. The anchor tags are the easy part. The hard parts are the relative paths, the junk links (javascript:void(0), mailto:, #anchor), the duplicates, and the links that do not exist in the raw HTML at all because a framework injected them client-side.
I have written this same extractor a dozen times. Here is the version that handles the edge cases you will actually hit.
What "extracting URLs" really means
There are more link sources on a page than most people expect. A thorough extractor looks at all of them, not just <a> tags.
| Source | HTML | What it is |
|---|---|---|
| Anchor links | <a href> |
Navigation, the main target |
| Images | <img src>, srcset |
Media you might also want |
| Scripts/styles | <script src>, <link href> |
Assets, usually filtered out |
| Iframes | <iframe src> |
Embedded pages |
| Sitemaps | sitemap.xml |
A pre-built list of every URL |
| Canonical/alternate | <link rel> |
SEO hints, other language versions |
For most jobs you want anchor href values. But if you are building a crawler or an asset downloader, know that the other sources exist.
Step 1: Get all anchor hrefs
Start with the raw HTML. Here is the Python version.
import requests
from bs4 import BeautifulSoup
resp = requests.get("https://example.com", timeout=15)
soup = BeautifulSoup(resp.text, "html.parser")
hrefs = [a.get("href") for a in soup.find_all("a") if a.get("href")]
print(hrefs)
And the Node version with Cheerio.
import * as cheerio from "cheerio";
const html = await fetch("https://example.com").then((r) => r.text());
const $ = cheerio.load(html);
const hrefs = $("a[href]")
.map((_, el) => $(el).attr("href"))
.get();
console.log(hrefs);
At this point you have raw href strings. Half of them are probably useless. Now the real work starts.
Step 2: Resolve relative URLs
A page can link to /about, ../pricing, contact.html, or //cdn.example.com/x. None of those are usable on their own. You have to resolve each against the page's base URL.
Do not do this with string concatenation. Both languages ship a correct resolver.
from urllib.parse import urljoin, urldefrag
base = "https://example.com/docs/guide"
absolute = [urldefrag(urljoin(base, h))[0] for h in hrefs]
const base = "https://example.com/docs/guide";
const absolute = hrefs.map((h) => {
try {
const u = new URL(h, base);
u.hash = ""; // drop #fragments
return u.href;
} catch {
return null; // skips mailto:, javascript:, tel:
}
});
Wrapping new URL() in a try/catch is the cheap trick that filters out mailto:, tel:, and javascript: links, because those throw or resolve to a non-http scheme. Watch for that.
Step 3: Filter out the noise
Now decide what you actually want. Common filters:
- Drop non-HTTP schemes (
mailto:,tel:,javascript:). - Drop fragment-only links (
#section), which point back to the same page. - Separate internal (same host) from external links.
- Match a pattern, for example only
/blog/URLs.
from urllib.parse import urlparse
base_host = urlparse(base).netloc
def keep(url):
if not url:
return False
p = urlparse(url)
return p.scheme in ("http", "https")
clean = [u for u in absolute if keep(u)]
internal = [u for u in clean if urlparse(u).netloc == base_host]
external = [u for u in clean if urlparse(u).netloc != base_host]
Splitting internal from external is the single most common filter. Crawlers stay internal. Link auditors care about external. Pattern filtering (a simple if "/blog/" in u) layers on top.
Step 4: Deduplicate
Pages repeat links constantly: a header, a footer, and three in-body mentions of the same page. Dedupe before you do anything expensive with the list.
unique = sorted(set(clean))
const unique = [...new Set(absolute.filter(Boolean))];
One subtlety: https://example.com/page and https://example.com/page/ are different strings but usually the same page. If duplicates matter, normalize trailing slashes and lowercase the host before you dedupe. Do not normalize the path case, since many servers are case-sensitive.
Step 5: Handle JavaScript-rendered links
Here is where a lot of extractors quietly fail. If the page is a React, Vue, or Angular app, requests.get() returns a near-empty shell and your href list comes back tiny or empty. The links get injected into the DOM after the JavaScript runs.
You have two options. Render the page yourself with a headless browser, or call an API that renders it for you. For the browser route, see how to scrape JavaScript-rendered websites, which walks through Playwright and Puppeteer in depth.
The API route skips the browser entirely. link.sc renders the page and returns clean content, so the anchors are all present:
curl https://link.sc/v1/fetch \
-H "Authorization: Bearer lsc_..." \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "format": "markdown"}'
Markdown output keeps links as [text](url), so a quick regex like \[.*?\]\((https?://[^)]+)\) pulls every URL from the response with no DOM parsing at all. If you would rather have structured output, the fetch endpoint can return links as a JSON array.
Step 6: Skip the page and read the sitemap
Sometimes the fastest URL extractor is not an extractor. If a site has a sitemap.xml, it already lists every URL the owner wants indexed, complete and deduplicated.
import requests
from bs4 import BeautifulSoup
xml = requests.get("https://example.com/sitemap.xml", timeout=15).text
soup = BeautifulSoup(xml, "xml")
urls = [loc.text for loc in soup.find_all("loc")]
Check /sitemap.xml and /robots.txt (which often points to the sitemap) before you crawl anything. It saves you the crawl entirely. If you do need to walk the whole site, our guide on how to crawl an entire website covers the queue-and-visited-set pattern.
Choosing your approach
| Situation | Best tool |
|---|---|
| Static HTML, small job | requests + BeautifulSoup |
| Static HTML, Node project | fetch + Cheerio |
| Links appear after JS | Headless browser or a rendering API |
| Full site inventory | sitemap.xml first |
| No infra, just want the links | curl to a fetch API |
A note on ethics and rate limits
Extract links from public pages, not from behind logins. Respect robots.txt, and if you are extracting from many pages, add a delay between requests so you do not hammer the server. A link extractor that fires hundreds of requests a second is indistinguishable from a small attack, and it will get your IP blocked. Slow and polite beats fast and banned.
Putting it together
A production URL extractor is five small steps: read the hrefs, resolve them to absolute, filter, dedupe, and handle the JS case. The code is short. The value is in getting the edge cases right, especially relative resolution and the client-rendered links that never show up in raw HTML.
If you want to skip the browser overhead for that last case, an API that renders and returns clean, link-preserving output does the extraction for you in one request.
Want every link from any page, rendered and clean, in one API call? Try link.sc free.