🧞 URL Genie 🧞
URL extraction, generalization, validation, and filtration made easy.
🚀 About URL Genie
A python package based on research involving over 2 million URLs, designed to handle URLs in a flexible manner for data-driven projects.
Version 2 is a rewrite with an RFC 3986 compliant core and small, explicit functions
in place of one generalize() carrying a dozen flags. See
Migrating from 1.x if you are upgrading.
✨ Features
- RFC 3986 compliant parsing, normalization and validation.
- Social profile recognition for Facebook, LinkedIn, Twitter/X, Instagram and YouTube.
- Email, phone and social extraction from free text, with validation.
- Domain-scoped email filtering, to drop contacts unrelated to a site.
- Duplicate reduction: every spelling of a profile collapses to one canonical URL.
- Public suffix validation against the bundled PSL snapshot -- no network access.
- Pure functions: no hidden state, safe to use across threads and processes.
⚙️ Installation
python -m pip install urlgenie
Depends only on tldextract, used offline against its bundled public suffix list.
♨️ Usage
from urlgenie import generalize, generalize_social, extract_social_handle
print(generalize("cnn.com/sports/about?a=b#c"))
>> https://cnn.com/sports/about
print(generalize("facebook.com.br/@Ahmed.Khatib"))
>> https://www.facebook.com/ahmedkhatib
print(generalize_social("x.com/elonmusk"))
>> https://twitter.com/elonmusk
print(extract_social_handle("x.com/elonmusk").handle)
>> elonmusk
Everything returns None on invalid input rather than a magic string, so use
generalize(url) or "Bad Url" if you want a sentinel:
df["clean"] = df["url"].apply(lambda u: generalize(u) or "Bad Url")
For a one-off batch, generalize_many keeps every entry in place -- an invalid
URL comes back as None rather than being silently dropped, so nothing shifts
out of alignment with your input:
from urlgenie import generalize_many
for original, result in generalize_many("facebook.com,facebook/com,dummy.com,instagram.com/ahmed"):
print(f"{original:22} -> {result}")
>> facebook.com -> https://facebook.com
>> facebook/com -> None
>> dummy.com -> https://dummy.com
>> instagram.com/ahmed -> https://www.instagram.com/ahmed
For a one-off run where you only want the URLs that came out clean and do not
need to know which input produced which, pass drop_invalid=True:
generalize_many("facebook.com,facebook/com,dummy.com,instagram.com/ahmed", drop_invalid=True)
>> [('facebook.com', 'https://facebook.com'), ('dummy.com', 'https://dummy.com'), ('instagram.com/ahmed', 'https://www.instagram.com/ahmed')]
print(generalize("random.haz/somePath") or "Bad Url")
>> Bad Url
Validating a social URL against an expected platform
A form field asking for a Facebook profile has to check two different things: is this a Facebook URL at all, and does it actually point at a profile?
from urlgenie import validate_social_platform, validate_social_profile
url = "facebook.com/profile.php"
print(validate_social_platform(url, "facebook"))
>> True
print(validate_social_profile(url, "facebook"))
>> False
print(validate_social_profile("facebook.com/profile.php?id=123123123", "facebook"))
>> True
profile.php alone belongs to Facebook but isn't a profile -- it's a reserved
path with no id. Adding ?id=... is what makes it one.
Extracting contacts from a page
from urlgenie import extract_contacts, validate_contacts
result = extract_contacts(scraped_text)
print(result.emails)
>> {'sales@example.com', 'someone@gmail.com'}
print(result.facebook)
>> {'https://www.facebook.com/example'}
print(result.phones)
>> {'+14155552671'}
#-Keep only emails belonging to the site being scraped-#
validated = validate_contacts(result, url="https://www.example.com/contact")
print(validated.emails)
>> {'sales@example.com'}
📋 API
| Function | Purpose |
|---|---|
generalize(url, social=True, ...) |
Canonical URL, collapsing recognized social links to their profile |
generalize_url(url, ...) |
Canonical URL only; keep_path / keep_query / keep_fragment / keep_userinfo / force_https / lower |
generalize_many(urls, drop_invalid=False, ...) |
Same for a delimited string or iterable; returns [(original, result), ...] in order, so an invalid entry is None in place rather than dropped. Pass drop_invalid=True to omit those pairs entirely when you only want the clean ones |
generalize_social(url) |
Canonical social profile URL, or None |
extract_social_handle(url) |
SocialHandle(platform, handle, url, original_handle, rule), or None |
detect_platform(url) / is_social_url(url) |
Platform lookup and membership test |
parse_url(url) |
RFC 3986 parse + normalize into a ParsedUrl, or None |
validate_url(url) |
Syntactic validity, public-suffix check, scheme allowlist |
validate_email(email, url=None) |
Syntax, RFC 5321 length limits, optional site-domain match |
validate_phone(phone) / normalize_phone(phone) |
Pragmatic digit-count validation |
validate_social(url) |
Whether a URL is a recognized profile, on any platform |
validate_social_platform(url, platform) |
Whether a URL's domain belongs to platform (e.g. "facebook"), regardless of whether the path resolves to a profile |
validate_social_profile(url, platform) |
Whether a URL resolves to an actual profile/handle on platform. facebook.com/profile.php passes the platform check but not this one |
extract_contacts(text, include=, exclude=) |
Emails, phones and socials from free text |
validate_contacts(result, url=None) |
Filter an ExtractResult; scope emails to a site |
🔀 Migrating from 1.x
Version 2 removes the UrlGenie class. Import the functions directly.
# 1.x
from urlgenie import UrlGenie
genie = UrlGenie(bad_url = "Bad Url", proper_tlds = True)
print(genie.generalize("fb.com/@ahmedkhatib"))
>> https://www.facebook.com/ahmedkhatib
# 2.x
from urlgenie import generalize
print(generalize("fb.com/@ahmedkhatib") or "Bad Url")
>> https://www.facebook.com/ahmedkhatib
| 1.x | 2.x |
|---|---|
UrlGenie(bad_url=..., bad_social=...) |
functions return None; use or "Bad Url" |
proper_tlds=True |
always on, via the public suffix list |
generalize(url) |
generalize(url) |
generalize(url, get_handle=True) |
extract_social_handle(url).handle |
generalize(url, get_domain=True) |
parse_url(url).domain |
generalize(url, get_domain_with_tld=True) |
parse_url(url).registrable_domain |
generalize(url, comma_separated=True) |
generalize_many(url) -- note this returns (original, result) pairs, not a flat list |
generalize(url, social_rectification=False) |
generalize(url, social=False) |
generalize(url, keep_periods=False) |
always on for Facebook, which ignores periods |
extract_from_text(text) |
extract_contacts(text) |
validate_result_dict(d, url=...) |
validate_contacts(result, url=...) |
update_subdomains() / get_subdomains() |
removed; generalize() keeps no state |
Behaviour changes worth knowing:
- URLs 1.x silently rejected now parse: TLDs longer than four characters, subdomain
labels longer than ten, ports, IDN hosts, IP literals, and
example.com?a=b. Measured against the Tranco top 1M, 1.x rejected 4.72% of real domains; 2.x rejects 0. - Twitter handles follow Twitter's actual rules (15 characters, no periods), so some strings 1.x accepted are now rejected.
- Facebook handles have periods stripped, since Facebook ignores them when resolving profiles. LinkedIn and Instagram periods are significant and preserved.
🧩 Adding a social pattern
Every pattern is a separate named Rule in urlgenie/config.py. Rules are tried in
order and the first yielding a non-reserved handle wins, so supporting a new URL shape
is one line and touches nothing else:
FACEBOOK_RULES = (
Rule("fb_query_id", re.compile(r"[?&](?:id|gid|__user)=(?P<id>\d{5,})", _I)),
Rule("fb_media_set", re.compile(r"[?&]set=a\.(?:\d+\.)*(?P<id>\d{5,})", _I)),
...
)
A rule exposes an id or handle named group; an optional subdir group or attribute
sets the canonical path prefix. transform= handles the rare case needing real logic,
such as LinkedIn's legacy /pub/ to /in/ slug conversion.
📐 RFC 3986
urlgenie/uri.py implements scheme and host case normalization (§3.1, §3.2.2), IDNA
punycoding, default-port removal (§3.2.3), dot-segment resolution (§5.2.4) and
percent-encoding normalization (§2.1, §6.2.2.2). The last is idempotent, unlike
quote(unquote(url)), which corrupts %2520 and literal %.
⚡ Performance
Roughly 60,000 URLs per second single-threaded (~17s for one million), measured over a
corpus of one million distinct URLs. Every function is pure, so multiprocessing.Pool
scales it linearly.
python tools/benchmark.py 1000000
🧪 Tests
python -m pytest tests -q # 287 tests
sample.csv at the repo root is the reference sheet and is treated as the spec,
read directly by the test suite: every row is asserted for both the generalized
URL and the extracted handle.
📖 Resources
⭐ Love It? Star It!
Just a simple click but would help me out ;)
Metadata
Release files for urlgenie 2.0.5
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| urlgenie-2.0.5.tar.gz | 34.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| urlgenie-2.0.5-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 61.5 kB
Release files / urlgenie-2.0.5.tar.gz
| Download URL | urlgenie-2.0.5.tar.gz |
|---|---|
| Size | 34.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e6b8f7c04cc711a362bbc86f123c5d696902cddb23b2b81ac69d38063a1de034
|
|
BLAKE2b-256 checksum How to use checksums |
d5ca05867e4f8ed669af6deb05caec8a754caa1e42679bf64d730fd04459f5f5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / urlgenie-2.0.5-py3-none-any.whl
| Download URL | urlgenie-2.0.5-py3-none-any.whl |
|---|---|
| Size | 27.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
dd0a60d1002c2503d868c57740f6fb6a80324a1210a9fd80b5ce2acf2942777d
|
|
BLAKE2b-256 checksum How to use checksums |
5550b6ca1744e85999a26099cfc70d72e1864b8ce322755587f74df649b8565e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|