<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[linkbruno.com notes]]></title><description><![CDATA[linkbruno.com notes]]></description><link>https://linkbruno.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Mon, 02 Nov 2026 00:50:43 GMT</lastBuildDate><atom:link href="https://linkbruno.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How to check mailto: and tel: links before they cost you leads]]></title><description><![CDATA[Link checkers usually skip mailto: and tel: links because there's no web page to request. But these are often the most valuable links on a site: the "email…]]></description><link>https://linkbruno.hashnode.dev/how-to-check-mailto-and-tel-links-before-they-cost-you-leads</link><guid isPermaLink="true">https://linkbruno.hashnode.dev/how-to-check-mailto-and-tel-links-before-they-cost-you-leads</guid><category><![CDATA[HTML]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Testing]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[linkbruno.com]]></dc:creator><pubDate>Sat, 31 Oct 2026 22:56:36 GMT</pubDate><content:encoded><![CDATA[<p>Link checkers usually skip <code>mailto:</code> and <code>tel:</code> links because there's no web page to request. But these are often the most valuable links on a site: the "email sales" button, the phone number in the header, the support address in the footer. When they're wrong, people can't reach you, and you won't get an error report because the message never arrives.</p>
<h2>Common ways they break</h2>
<ul>
<li><strong>Typos.</strong> <code>mailto:sales@exmaple.com</code> looks fine at a glance.</li>
<li><strong>Old addresses.</strong> A team inbox was retired, but the footer still links to it.</li>
<li><strong>Domain changes.</strong> After a rebrand, <code>mailto:</code> links still use the old domain, whose mail records may be gone.</li>
<li><strong>Formatting mistakes in tel: links.</strong> Spaces, letters or missing country codes make some phones refuse to dial.</li>
<li><strong>Encoding problems.</strong> Subjects and bodies with unencoded spaces or <code>&amp;</code> characters get cut off.</li>
</ul>
<h2>Collect them from your pages</h2>
<pre><code class="language-python">import re, requests
from urllib.parse import unquote

def contact_links(url):
    html = requests.get(url, timeout=15, headers={"User-Agent": "Mozilla/5.0"}).text
    return set(re.findall(r'href=["\']((?:mailto|tel):[^"\']+)', html, re.I))

for link in contact_links("https://example.com/contact"):
    print(unquote(link))
</code></pre>
<p>Run it over every URL in your sitemap and you'll have a full inventory. Usually it's a short list, which makes it easy to review by eye.</p>
<h2>Check mailto: links</h2>
<p>Strip the address from any query string:</p>
<pre><code class="language-python">from email.utils import parseaddr
import dns.resolver  # pip install dnspython

def check_mailto(link):
    addr = unquote(link[7:].split("?")[0])
    name, email = parseaddr(addr)
    if "@" not in email:
        return "invalid address"
    domain = email.rsplit("@", 1)[1]
    try:
        dns.resolver.resolve(domain, "MX")
        return "ok"
    except Exception as e:
        return f"no MX for {domain}: {type(e).__name__}"
</code></pre>
<p>A missing MX record means mail to that domain will almost certainly fail. A valid MX doesn't prove the mailbox exists. The only reliable test for that is to send a real message to every address on your site once in a while and see whether it arrives.</p>
<h2>Check tel: links</h2>
<p>The format is simple: the RFC recommends the full international number, with an optional <code>+</code> and digits, and visual separators like <code>-</code> and <code>.</code> are allowed:</p>
<pre><code class="language-python">import re
TEL_OK = re.compile(r'^tel:\+?[0-9][0-9\-\.\(\) ]{5,}$')

def check_tel(link):
    raw = unquote(link)
    if not TEL_OK.match(raw):
        return "unusual format"
    if not raw.startswith("tel:+"):
        return "no country code"
    return "ok"
</code></pre>
<p>Writing numbers in international form (<code>tel:+442071234567</code>) makes them work for visitors calling from any country. The visible text can still show the local format.</p>
<h2>Check the encoded parts</h2>
<p>For <code>mailto:</code> links with a subject or body, encode spaces as <code>%20</code>, not <code>+</code>:</p>
<pre><code class="language-html">&lt;a href="mailto:hello@example.com?subject=Demo%20request&amp;body=Hi%2C%20I%27d%20like%20a%20demo."&gt;
</code></pre>
<p>Test by clicking it on desktop and mobile; mail apps are less forgiving than browsers.</p>
<h2>Watch for obfuscation side effects</h2>
<p>Sites often hide email addresses from scrapers with JavaScript or CDN features. That's fine, but it means the address in the HTML isn't the one visitors get. Check the link in a real browser after the script runs, not just in the source.</p>
<h2>A quick manual review list</h2>
<ul>
<li><input disabled type="checkbox" /> Every contact address belongs to a mailbox someone reads</li>
<li><input disabled type="checkbox" /> Every phone number dials from a mobile phone</li>
<li><input disabled type="checkbox" /> Numbers use international format in the <code>tel:</code> link</li>
<li><input disabled type="checkbox" /> No old brand domains in <code>mailto:</code> links</li>
<li><input disabled type="checkbox" /> Contact links in emails and PDFs match the site</li>
</ul>
<h2>Keep the rest of the links in check</h2>
<p><code>mailto:</code> and <code>tel:</code> links need occasional manual review. The rest of your links can be checked automatically: <a href="https://linkbruno.com">linkbruno.com</a> crawls your pages on a schedule and alerts when a link or image breaks.</p>
<h2>Related reading</h2>
<ul>
<li><a href="https://linkbruno.hashnode.dev/changed-a-url-slug-a-cleanup-plan-for-every-link-that-pointed-to-it">Changed a URL slug? A cleanup plan for every link that pointed to it</a></li>
<li><a href="https://linkbruno.hashnode.dev/how-to-find-broken-links-inside-pdfs-and-downloadable-files">How to find broken links inside PDFs and downloadable files</a></li>
<li><a href="https://dev.to/linkbruno/check-links-in-markdown-docs-with-a-60-line-python-script-4hg6">Check links in Markdown docs with a 60-line Python script</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[How to find broken links inside PDFs and downloadable files]]></title><description><![CDATA[Most link checkers look at HTML pages. But plenty of sites publish important content as PDFs: whitepapers, manuals, price lists, research reports. Those…]]></description><link>https://linkbruno.hashnode.dev/how-to-find-broken-links-inside-pdfs-and-downloadable-files</link><guid isPermaLink="true">https://linkbruno.hashnode.dev/how-to-find-broken-links-inside-pdfs-and-downloadable-files</guid><category><![CDATA[pdf]]></category><category><![CDATA[Python]]></category><category><![CDATA[documentation]]></category><category><![CDATA[SEO]]></category><dc:creator><![CDATA[linkbruno.com]]></dc:creator><pubDate>Sat, 31 Oct 2026 22:55:45 GMT</pubDate><content:encoded><![CDATA[<p>Most link checkers look at HTML pages. But plenty of sites publish important content as PDFs: whitepapers, manuals, price lists, research reports. Those files get downloaded, emailed and archived, and the links inside them break like any others. Nobody notices, because nobody re-reads a PDF from 2021. Here's how to check them.</p>
<h2>Why PDF links break more often</h2>
<ul>
<li>A PDF can't be edited in place. Fixing a link means regenerating and re-uploading the file.</li>
<li>PDFs are often created by people outside the web team, with links to internal drafts or staging URLs.</li>
<li>They live for years. A link that was fine on publication day may not survive a site redesign.</li>
<li>Copies of the old file keep circulating, so even after a fix, the old version still has the broken link.</li>
</ul>
<h2>Extract links with Python</h2>
<p>The <code>pypdf</code> library can read link annotations, which is where clickable links live:</p>
<pre><code class="language-python">from pypdf import PdfReader

def pdf_links(path):
    reader = PdfReader(path)
    for page_num, page in enumerate(reader.pages, start=1):
        for annot in page.get("/Annots") or []:
            obj = annot.get_object()
            action = obj.get("/A")
            if action and action.get("/URI"):
                yield page_num, action["/URI"]

for page, uri in pdf_links("report.pdf"):
    print(page, uri)
</code></pre>
<p>Some PDFs also contain URLs as plain text that aren't clickable. To catch those, extract the text and search it:</p>
<pre><code class="language-python">import re
text_urls = set()
for page in PdfReader("report.pdf").pages:
    text_urls.update(re.findall(r'https?://[^\s)&gt;\]]+', page.extract_text() or ""))
</code></pre>
<p>Text extraction can split a long URL across lines, so treat these results as hints to check by hand.</p>
<h2>Check the URLs</h2>
<pre><code class="language-python">import requests

def status(url):
    try:
        r = requests.head(url, allow_redirects=True, timeout=10,
                          headers={"User-Agent": "Mozilla/5.0"})
        if r.status_code in (403, 405):
            r = requests.get(url, allow_redirects=True, timeout=15, stream=True,
                             headers={"User-Agent": "Mozilla/5.0"})
        return r.status_code
    except requests.RequestException as e:
        return type(e).__name__

for page, uri in pdf_links("report.pdf"):
    s = status(uri)
    if s != 200:
        print(f"page {page}: {uri} -&gt; {s}")
</code></pre>
<h2>Check every PDF on your site</h2>
<p>Gather all PDF URLs first. If they're linked from your pages, your sitemap or a crawl will find them. If you have server access:</p>
<pre><code class="language-bash">find /var/www -name '*.pdf' &gt; pdfs.txt
</code></pre>
<p>Then loop over the files with the functions above and write a CSV of page, link and status that the content owner can work through.</p>
<h2>Don't forget Word, Excel and slides</h2>
<p>Office files (<code>.docx</code>, <code>.xlsx</code>, <code>.pptx</code>) are ZIP archives with XML inside. Hyperlinks are stored in relationship files:</p>
<pre><code class="language-bash">unzip -p guide.docx 'word/_rels/document.xml.rels' | grep -o 'Target="http[^"]*"'
</code></pre>
<p>That's enough to list the external links for checking.</p>
<h2>Fixing broken PDF links</h2>
<p>You have three options:</p>
<ol>
<li><strong>Regenerate the PDF</strong> from the source document with corrected links and replace the file at the same URL. Readers who download it again get the fix.</li>
<li><strong>Redirect the broken target.</strong> If the dead link points to your own site, a 301 from the old URL fixes every existing copy of the PDF at once, including ones already downloaded. This is often the best fix.</li>
<li><strong>Use stable short paths in future PDFs.</strong> Links like <code>example.com/go/pricing</code> that you redirect on your own server can be updated forever without touching the PDF.</li>
</ol>
<h2>Don't skip the links pointing to PDFs</h2>
<p>The other direction breaks too: pages that link to a PDF that was renamed or deleted. A regular crawl of your pages catches those. <a href="https://linkbruno.com">linkbruno.com</a> checks every link on your pages on a schedule, including links to files, and tells you which page the broken link is on.</p>
<h2>Related reading</h2>
<ul>
<li><a href="https://dev.to/linkbruno/how-to-find-mixed-content-http-links-on-an-https-site-1d69">How to find mixed content (http:// links) on an HTTPS site</a></li>
<li><a href="https://linkbruno.hashnode.dev/changed-a-url-slug-a-cleanup-plan-for-every-link-that-pointed-to-it">Changed a URL slug? A cleanup plan for every link that pointed to it</a></li>
<li><a href="https://telegra.ph/Too-many-broken-links-How-to-decide-which-to-fix-first-10-31">Too many broken links? How to decide which to fix first</a></li>
<li><a href="https://telegra.ph/Why-links-in-old-email-newsletters-break-and-how-to-prevent-it-10-31">Why links in old email newsletters break, and how to prevent it</a></li>
<li><a href="https://telegra.ph/Trailing-slashes-the-small-URL-detail-that-creates-redirects-10-31">Trailing slashes: the small URL detail that creates redirects</a></li>
<li><a href="https://linkbruno.hashnode.dev/how-to-check-mailto-and-tel-links-before-they-cost-you-leads">How to check mailto: and tel: links before they cost you leads</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Changed a URL slug? A cleanup plan for every link that pointed to it]]></title><description><![CDATA[Renaming a page is easy. Every CMS lets you edit the slug in a second. What's harder is everything that still points to the old address: your own internal…]]></description><link>https://linkbruno.hashnode.dev/changed-a-url-slug-a-cleanup-plan-for-every-link-that-pointed-to-it</link><guid isPermaLink="true">https://linkbruno.hashnode.dev/changed-a-url-slug-a-cleanup-plan-for-every-link-that-pointed-to-it</guid><category><![CDATA[SEO]]></category><category><![CDATA[redirects]]></category><category><![CDATA[webdev]]></category><category><![CDATA[content]]></category><dc:creator><![CDATA[linkbruno.com]]></dc:creator><pubDate>Sat, 31 Oct 2026 22:54:54 GMT</pubDate><content:encoded><![CDATA[<p>Renaming a page is easy. Every CMS lets you edit the slug in a second. What's harder is everything that still points to the old address: your own internal links, your sitemap, other sites, bookmarks, and old emails. Here's a short plan to do it without leaving a trail of 404s.</p>
<h2>Step 1: write down the old and new URL</h2>
<p>Sounds obvious, but when several pages change at once, a simple two-column list saves hours later:</p>
<pre><code class="language-plaintext">old,new
/blog/broken-link-tips,/blog/find-broken-links
/pricing-old,/pricing
</code></pre>
<p>Keep it in the repo or wherever your redirects live.</p>
<h2>Step 2: add a 301 redirect before you change anything else</h2>
<p>The redirect is what protects external links and bookmarks you can't edit. In nginx:</p>
<pre><code class="language-nginx">location = /blog/broken-link-tips { return 301 /blog/find-broken-links; }
</code></pre>
<p>In Apache <code>.htaccess</code>:</p>
<pre><code class="language-apache">Redirect 301 /blog/broken-link-tips /blog/find-broken-links
</code></pre>
<p>Most CMSs and hosts (WordPress plugins, Netlify <code>_redirects</code>, Vercel <code>redirects</code> in config) have an equivalent. Test it:</p>
<pre><code class="language-bash">curl -sI https://example.com/blog/broken-link-tips | grep -iE '^(HTTP|location)'
</code></pre>
<p>You want one <code>301</code> and a <code>location</code> pointing straight to the new URL.</p>
<h2>Step 3: avoid chains</h2>
<p>If this page was renamed before, an old redirect may now point to the URL you just retired:</p>
<pre><code class="language-plaintext">/2019-post -&gt; /blog/broken-link-tips -&gt; /blog/find-broken-links
</code></pre>
<p>Update the oldest rule so it points directly to the final URL. Each extra hop adds latency and is one more thing that can break.</p>
<h2>Step 4: update internal links</h2>
<p>A redirect makes internal links work, but they should still point to the real address. Search your content for the old path:</p>
<pre><code class="language-bash">grep -rn '/blog/broken-link-tips' content/ templates/
</code></pre>
<p>In WordPress, WP-CLI can search the database:</p>
<pre><code class="language-bash">wp db search '/blog/broken-link-tips' --all-tables
</code></pre>
<p>Don't forget menus, footers, sidebar widgets, and "related posts" blocks that store URLs separately from the post body.</p>
<h2>Step 5: update the sitemap and canonical</h2>
<p>Your XML sitemap should list only the new URL. If it's generated automatically, check it regenerated. Make sure the new page's canonical tag points to itself, not to the old address.</p>
<h2>Step 6: the links you don't control</h2>
<p>You can't edit other sites, but you can ask. For a page with important backlinks, find the sites linking to the old URL (Search Console's Links report shows top linking sites), and send a short note to the ones that matter most. The redirect covers you either way; the update just removes a hop.</p>
<p>Also update the places you do control outside the site: social profiles, email signatures, email templates, documentation, app store listings, and ad campaigns.</p>
<h2>Step 7: check again a week later</h2>
<p>Redirect rules get overwritten by deploys, plugins get updated, and someone else renames a page that links to yours. A quick re-check after a week catches most problems:</p>
<ul>
<li>Old URL still returns 301 to the new one</li>
<li>New URL returns 200</li>
<li>No internal links to the old URL remain</li>
<li>Sitemap lists only the new URL</li>
</ul>
<h2>When to skip the redirect</h2>
<p>If the old page had no traffic, no backlinks and was never shared, a redirect is still cheap insurance. The only real reason to skip it is when the content is gone for good with no replacement, in which case a 410 is more honest than redirecting to the homepage.</p>
<h2>Automate the boring part</h2>
<p>Steps 4 and 7 are exactly what a scheduled link check is for. <a href="https://linkbruno.com">linkbruno.com</a> crawls a site on a schedule, follows redirects hop by hop to show each chain, and alerts when an internal link breaks, with the page it's on.</p>
<h2>Related reading</h2>
<ul>
<li><a href="https://dev.to/linkbruno/check-links-in-markdown-docs-with-a-60-line-python-script-4hg6">Check links in Markdown docs with a 60-line Python script</a></li>
<li><a href="https://dev.to/linkbruno/how-to-find-mixed-content-http-links-on-an-https-site-1d69">How to find mixed content (http:// links) on an HTTPS site</a></li>
<li><a href="https://telegra.ph/Too-many-broken-links-How-to-decide-which-to-fix-first-10-31">Too many broken links? How to decide which to fix first</a></li>
<li><a href="https://telegra.ph/Why-links-in-old-email-newsletters-break-and-how-to-prevent-it-10-31">Why links in old email newsletters break, and how to prevent it</a></li>
<li><a href="https://telegra.ph/Trailing-slashes-the-small-URL-detail-that-creates-redirects-10-31">Trailing slashes: the small URL detail that creates redirects</a></li>
<li><a href="https://linkbruno.hashnode.dev/how-to-check-mailto-and-tel-links-before-they-cost-you-leads">How to check mailto: and tel: links before they cost you leads</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[How to find broken links with Selenium and Python (without false alarms)]]></title><description><![CDATA["Find all broken links on a page" is a classic Selenium exercise, and most examples stop at a loop with requests.head(). That version works on a demo page…]]></description><link>https://linkbruno.hashnode.dev/how-to-find-broken-links-with-selenium-and-python-without-false-alarms</link><guid isPermaLink="true">https://linkbruno.hashnode.dev/how-to-find-broken-links-with-selenium-and-python-without-false-alarms</guid><category><![CDATA[selenium]]></category><category><![CDATA[Python]]></category><category><![CDATA[Testing]]></category><category><![CDATA[QA]]></category><dc:creator><![CDATA[linkbruno.com]]></dc:creator><pubDate>Tue, 27 Oct 2026 11:24:33 GMT</pubDate><content:encoded><![CDATA[<p>"Find all broken links on a page" is a classic Selenium exercise, and most examples stop at a loop with <code>requests.head()</code>. That version works on a demo page and falls apart on a real site: it flags working links as broken, misses some broken ones, and gets your IP rate-limited. Here is a version that holds up, in Python.</p>
<h2>What Selenium is for here (and what it isn't)</h2>
<p>Selenium can't read HTTP status codes; WebDriver doesn't expose them. So the job splits in two:</p>
<ol>
<li><strong>Selenium</strong> loads the page in a real browser and collects the links, including ones added by JavaScript.</li>
<li><strong>An HTTP client</strong> (<code>requests</code>) checks each link's status.</li>
</ol>
<h2>Setup</h2>
<pre><code class="language-bash">pip install selenium requests
</code></pre>
<p>Selenium 4.6+ downloads a matching browser driver automatically, so no manual chromedriver setup is needed.</p>
<h2>Step 1: collect the links</h2>
<pre><code class="language-python">from urllib.parse import urldefrag
from selenium import webdriver
from selenium.webdriver.common.by import By

START_URL = "https://staging.example.com/"

options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
driver = webdriver.Chrome(options=options)

driver.get(START_URL)
hrefs = {a.get_attribute("href") for a in driver.find_elements(By.TAG_NAME, "a")}
driver.quit()

links = sorted({urldefrag(h)[0] for h in hrefs if h and h.startswith("http")})
print(f"{len(links)} unique links")
</code></pre>
<p><code>get_attribute("href")</code> returns the resolved, absolute URL, so relative links are handled. The filter drops empty <code>href</code>s and <code>mailto:</code>, <code>tel:</code> and <code>javascript:</code> links. <code>urldefrag</code> removes <code>#section</code> parts, because the server never sees them and you'd otherwise check the same page repeatedly.</p>
<h2>Step 2: check each link properly</h2>
<pre><code class="language-python">import time
import requests

session = requests.Session()
session.headers["User-Agent"] = "Mozilla/5.0 (link check; +internal QA)"

def check(url):
    try:
        r = session.head(url, allow_redirects=True, timeout=15)
        if r.status_code in (403, 405, 501):          # HEAD refused: try GET
            r = session.get(url, allow_redirects=True, timeout=15, stream=True)
            r.close()
        return r.status_code, len(r.history), r.url
    except requests.RequestException as e:
        return 0, 0, type(e).__name__
</code></pre>
<p>What this fixes compared with the usual tutorial:</p>
<ul>
<li><code>allow_redirects=True</code><strong>.</strong> <code>requests.head()</code> does <em>not</em> follow redirects by default, so a moved page reports 301 and looks fine even if the destination is a 404.</li>
<li><strong>GET fallback.</strong> Many servers answer HEAD with 405 or 403. <code>stream=True</code> avoids downloading the whole body.</li>
<li><strong>Timeouts.</strong> Without one, a single dead server can hang the run.</li>
<li><code>len(r.history)</code> tells you how many redirects it took, so you can spot long chains worth updating.</li>
</ul>
<h2>Step 3: be polite and avoid rate limits</h2>
<p>Checking 200 links on the same host as fast as possible will earn you a <code>429 Too Many Requests</code>. Add a short delay per host:</p>
<pre><code class="language-python">from urllib.parse import urlparse

last_hit = {}

def polite_check(url, gap=1.0):
    host = urlparse(url).netloc
    wait = gap - (time.time() - last_hit.get(host, 0))
    if wait &gt; 0:
        time.sleep(wait)
    last_hit[host] = time.time()
    return check(url)
</code></pre>
<h2>Step 4: classify the results</h2>
<p>Not every non-200 response means broken. Separate them:</p>
<pre><code class="language-python">BLOCKED = {401, 403, 429, 999}     # 999 is LinkedIn refusing automated requests

broken, blocked, slow_chains = [], [], []
for url in links:
    status, hops, final = polite_check(url)
    if status == 0 or (status &gt;= 400 and status not in BLOCKED):
        # Re-check once before calling it broken: servers hiccup.
        time.sleep(5)
        status, hops, final = check(url)
        if status == 0 or (status &gt;= 400 and status not in BLOCKED):
            broken.append((status, url, final))
            continue
    if status in BLOCKED:
        blocked.append((status, url))
    elif hops &gt;= 3:
        slow_chains.append((hops, url, final))

for s, u, f in broken:
    print(f"BROKEN {s}  {u}  -&gt; {f}")
for s, u in blocked:
    print(f"COULD NOT VERIFY {s}  {u}")
for h, u, f in slow_chains:
    print(f"{h} REDIRECTS  {u}  -&gt; {f}")
</code></pre>
<p>The single re-check removes most false alarms caused by a server that blinked. The "could not verify" bucket is important: those links usually work for people, and failing a test because of them trains everyone to ignore the test.</p>
<h2>Step 5: make it a test</h2>
<p>Wrap it in pytest so it runs with the rest of your suite:</p>
<pre><code class="language-python">def test_no_broken_links():
    assert not broken, "\n".join(f"{s} {u}" for s, u, _ in broken)
</code></pre>
<h2>Things a status check won't catch</h2>
<ul>
<li><strong>Soft 404s:</strong> a page that answers 200 but says "Page not found". Check the response text for your site's not-found wording.</li>
<li><strong>Missing anchors:</strong> <code>/docs#install</code> returns 200 even after the <code>install</code> heading is gone. Load the page with Selenium and check <code>driver.find_elements(By.ID, "install")</code>.</li>
<li><strong>Parked domains:</strong> a link to an expired domain often returns 200 with an ad page.</li>
</ul>
<h2>One run vs. monitoring</h2>
<p>This script tells you what is broken today. Links on a live site break gradually, long after the test passed, as other sites move or disappear. Run the script on a schedule, or use a hosted monitor such as <a href="https://linkbruno.com">linkbruno.com</a> that re-crawls your site and alerts by email or Slack when a link stops working.</p>
<p>Either way, the three fixes above (follow redirects, fall back to GET, re-check before failing) are what turn the textbook Selenium exercise into a check you can trust.</p>
<h2>Related reading</h2>
<ul>
<li><a href="https://telegra.ph/Why-links-in-old-email-newsletters-break-and-how-to-prevent-it-10-31">Why links in old email newsletters break, and how to prevent it</a></li>
<li><a href="https://dev.to/linkbruno/how-to-find-mixed-content-http-links-on-an-https-site-1d69">How to find mixed content (http:// links) on an HTTPS site</a></li>
<li><a href="https://linkbruno.hashnode.dev/how-to-find-broken-links-inside-pdfs-and-downloadable-files">How to find broken links inside PDFs and downloadable files</a></li>
<li><a href="https://telegra.ph/Trailing-slashes-the-small-URL-detail-that-creates-redirects-10-31">Trailing slashes: the small URL detail that creates redirects</a></li>
</ul>
]]></content:encoded></item></channel></rss>