Most small business sites I look at don't have exotic problems. They have the same four boring ones:
- The TLS certificate is expired or about to expire, so browsers show a full-page warning.
-
http://doesn't redirect tohttps://, so old links and some search results land on the insecure version. - The page is HTTPS but still loads images, scripts or stylesheets over
http://(mixed content). Browsers upgrade or block them, and the padlock can disappear. - There's no responsive viewport tag, so phones render a tiny zoomed-out desktop page.
All four can be seen from the outside with a normal page load, so you can check them without any access to the site. Here's a small script that does exactly that, and then the WordPress fix for each one.
The script
Standard library only, so it runs anywhere Python 3.8+ is installed. It loads the homepage like a browser: no scanning, no guessing paths, no logins.
#!/usr/bin/env python3
"""site_check.py - a quick, polite HTTPS + mobile sanity check for a small business site.
Standard library only (Python 3.8+). It loads the homepage like a normal browser
would: no port scans, no login attempts, no path guessing.
Usage: python3 site_check.py example.com [another-site.co.uk ...]
"""
import socket
import ssl
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone
from html.parser import HTMLParser
UA = "Mozilla/5.0 (site_check.py; quick public health check)"
TIMEOUT = 15
class PageScan(HTMLParser):
"""Collects the viewport tag and any http:// sub-resources on the page."""
# tag -> attributes that make the browser fetch something
RESOURCE_ATTRS = {
"img": ("src", "srcset"), "script": ("src",), "iframe": ("src",),
"source": ("src", "srcset"), "video": ("src", "poster"),
"audio": ("src",), "embed": ("src",), "object": ("data",),
}
def __init__(self):
super().__init__()
self.viewport = None
self.insecure = []
def handle_starttag(self, tag, attrs):
a = {k.lower(): (v or "") for k, v in attrs}
if tag == "meta" and a.get("name", "").lower() == "viewport":
self.viewport = a.get("content", "")
if tag == "link" and "stylesheet" in a.get("rel", "").lower():
self._check(tag, a.get("href", ""))
for attr in self.RESOURCE_ATTRS.get(tag, ()):
self._check(tag, a.get(attr, ""))
def _check(self, tag, value):
# srcset can hold several URLs: "a.jpg 1x, b.jpg 2x"
for part in value.split(","):
url = part.strip().split(" ")[0]
if url.lower().startswith("http://"):
self.insecure.append(f"<{tag}> {url}")
def cert_days_left(host):
"""Days until the TLS certificate expires, or the error string if the handshake fails."""
ctx = ssl.create_default_context()
try:
with socket.create_connection((host, 443), timeout=TIMEOUT) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as tls:
not_after = tls.getpeercert()["notAfter"]
except ssl.SSLCertVerificationError as e:
return f"certificate problem: {e.verify_message}"
except (OSError, ssl.SSLError) as e:
return f"no HTTPS connection: {e}"
expires = datetime.fromtimestamp(ssl.cert_time_to_seconds(not_after), timezone.utc)
return (expires - datetime.now(timezone.utc)).days
def fetch(url):
"""Follow redirects like a browser. Returns (final_url, status, html)."""
req = urllib.request.Request(url, headers={"User-Agent": UA})
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
return r.geturl(), r.status, r.read(2_000_000).decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.geturl(), e.code, ""
def check(host):
host = host.replace("https://", "").replace("http://", "").strip("/")
print(f"\n== {host}")
problems = 0
# 1. Does the certificate validate, and when does it expire?
days = cert_days_left(host)
if isinstance(days, int):
flag = "OK" if days > 14 else "WARN"
problems += flag != "OK"
print(f" [{flag}] certificate valid, expires in {days} days")
else:
problems += 1
print(f" [FAIL] {days}")
# 2. Does plain http:// send visitors to https://?
try:
final, status, _ = fetch(f"http://{host}/")
if final.startswith("https://"):
print(f" [OK] http:// redirects to {final}")
else:
problems += 1
print(f" [FAIL] http:// stays on {final} (status {status}), no redirect to HTTPS")
except Exception as e: # noqa: BLE001 - report, don't crash on one bad site
print(f" [INFO] http:// check skipped: {e}")
# 3. Mixed content and viewport on the HTTPS homepage
try:
final, status, html = fetch(f"https://{host}/")
except Exception as e: # noqa: BLE001
print(f" [FAIL] could not load https://{host}/ ({e})")
return problems + 1
scan = PageScan()
scan.feed(html)
if scan.insecure:
problems += 1
print(f" [FAIL] {len(scan.insecure)} http:// resource(s) on an HTTPS page (mixed content):")
for item in scan.insecure[:5]:
print(f" {item}")
else:
print(" [OK] no http:// images, scripts or stylesheets found in the HTML")
if scan.viewport and "width=device-width" in scan.viewport.replace(" ", ""):
print(f' [OK] viewport: "{scan.viewport}"')
else:
problems += 1
print(" [FAIL] no responsive viewport tag, so phones will show a zoomed-out desktop page")
return problems
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit(__doc__)
total = sum(check(h) for h in sys.argv[1:])
print(f"\n{total} problem(s) found")
sys.exit(1 if total else 0)
Testing it
badssl.com runs deliberately broken test hosts, which makes it a good way to see each check fire:
python3 site_check.py badssl.com expired.badssl.com mixed.badssl.com http.badssl.com wordpress.org
== badssl.com
[OK] certificate valid, expires in 74 days
[OK] http:// redirects to https://badssl.com/
[OK] no http:// images, scripts or stylesheets found in the HTML
[OK] viewport: "width=device-width, initial-scale=1.0"
== expired.badssl.com
[FAIL] certificate problem: certificate has expired
[INFO] http:// check skipped: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1010)>
[FAIL] could not load https://expired.badssl.com/ (<urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1010)>)
== mixed.badssl.com
[OK] certificate valid, expires in 74 days
[OK] http:// redirects to https://mixed.badssl.com/
[FAIL] 1 http:// resource(s) on an HTTPS page (mixed content):
<img> http://mixed.badssl.com/image.jpg
[OK] viewport: "width=device-width, initial-scale=1"
== http.badssl.com
[OK] certificate valid, expires in 74 days
[FAIL] http:// stays on http://http.badssl.com/ (status 200), no redirect to HTTPS
[OK] no http:// images, scripts or stylesheets found in the HTML
[OK] viewport: "width=device-width, initial-scale=1"
== wordpress.org
[OK] certificate valid, expires in 68 days
[OK] http:// redirects to https://wordpress.org/
[OK] no http:// images, scripts or stylesheets found in the HTML
[OK] viewport: "width=device-width, initial-scale=1"
4 problem(s) found
The expired certificate, the missing redirect on http.badssl.com and the insecure image on mixed.badssl.com are all caught; the healthy sites pass. The exit code is 1 when anything fails, so you can drop it into a cron job or CI step for the sites you look after.
Fixing each one on WordPress
Always take a backup first (your host's backup tool, or wp db export plus a copy of wp-content).
1. Expired or failing certificate
On most shared hosting the certificate is a free Let's Encrypt one that renews automatically, and an expired one usually means the renewal broke: the domain's DNS moved, or a CDN/proxy sits in front of the site. Look in the host's SSL/TLS panel first and re-issue there. If the site is behind Cloudflare, check that its SSL mode matches what the origin actually serves.
2. No redirect from http:// to https://
First make sure WordPress itself knows its address is HTTPS: Settings → General, set both WordPress Address (URL) and Site Address (URL) to https://.... Then add a server-level 301. On Apache, near the top of .htaccess (above the # BEGIN WordPress block):
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}/$1 [L,R=301]
One trap: behind a proxy or CDN, %{HTTPS} can be off even for HTTPS visitors, which gives you a redirect loop. In that case do the redirect at the CDN (Cloudflare's "Always Use HTTPS") instead of in .htaccess. On Nginx it's a return 301 https://$host$request_uri; in the port-80 server block.
It's also worth adding this to wp-config.php so the dashboard is always served over HTTPS:
define( 'FORCE_SSL_ADMIN', true );
3. Mixed content
This almost always comes from old absolute URLs saved in the database (post content, widgets, page-builder data) from before the site moved to HTTPS. With WP-CLI, do a dry run first:
wp search-replace 'http://example.com' 'https://example.com' --all-tables --dry-run
wp search-replace 'http://example.com' 'https://example.com' --all-tables
wp search-replace handles PHP-serialized data, which a raw SQL REPLACE() would corrupt. Some page builders keep generated CSS files with the old URLs, so regenerate those afterwards (in Elementor: Tools → Regenerate CSS & Data). Anything still left is usually hard-coded in a theme or plugin file, or comes from a third-party embed that only supports http://; replace or remove it.
The script only reads the homepage HTML, so it won't see URLs inside CSS files or resources injected by JavaScript. For the full list, open the browser DevTools console: mixed content warnings name each URL.
4. No viewport tag
Any reasonably modern theme includes it, so a missing viewport usually means a very old or heavily customised theme. The real fix is a responsive theme, because the tag alone won't make a fixed-width layout fit a phone. If the layout already uses responsive CSS and only the tag is missing, add it from a child theme's functions.php:
add_action( 'wp_head', function () {
echo '<meta name="viewport" content="width=device-width, initial-scale=1">' . "\n";
}, 1 );
Check the page source afterwards so you don't end up with two viewport tags.
Wrapping up
None of this is hard, but it's easy to miss when nobody is looking after the site day to day. Running a check like this monthly catches a certificate that's about to lapse before customers see the warning.
I'm Shanmukh, a student and solo developer. I fix exactly these issues (HTTPS, mobile layout, forms) on small business sites, and build simple 5-page sites, as fixed-scope freelance work. If you'd rather hand it off, the packages are on Fiverr and Whop. More code is on GitHub.
Top comments (0)