Skip to main content

all: initial commit

ID
c0cd2d7
date
2026-09-27 06:41:29+00:00
author
Alex Chan <alex@alexwlchan.net>
message
all: initial commit
changed files
8 files, 1252 additions

Changed files

.gitignore (0 → 8)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target

CHANGELOG.md (0 → 133)

diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..b6d3630
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,6 @@
+# CHANGELOG
+
+## v1.0.0 - 2026-09-27
+
+Initial release.
+This release includes a lot of URL cleaning logic ported from a previous tool.

Cargo.lock (0 → 10157)

diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..b7c3025
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,386 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "assert_cmd"
+version = "2.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6"
+dependencies = [
+ "anstyle",
+ "bstr",
+ "libc",
+ "predicates",
+ "predicates-core",
+ "predicates-tree",
+ "wait-timeout",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "bstr"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
+dependencies = [
+ "memchr",
+ "regex-automata",
+ "serde_core",
+]
+
+[[package]]
+name = "clap"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
+
+[[package]]
+name = "clean-url"
+version = "1.0.0"
+dependencies = [
+ "anyhow",
+ "assert_cmd",
+ "clap",
+ "predicates",
+ "urlencoding",
+ "urlparse",
+]
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "difflib"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
+
+[[package]]
+name = "float-cmp"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "normalize-line-endings"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "predicates"
+version = "3.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe"
+dependencies = [
+ "anstyle",
+ "difflib",
+ "float-cmp",
+ "normalize-line-endings",
+ "predicates-core",
+ "regex",
+]
+
+[[package]]
+name = "predicates-core"
+version = "1.0.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144"
+
+[[package]]
+name = "predicates-tree"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2"
+dependencies = [
+ "predicates-core",
+ "termtree",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "termtree"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "urlencoding"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a1f0175e03a0973cf4afd476bef05c26e228520400eb1fd473ad417b1c00ffb"
+
+[[package]]
+name = "urlparse"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "110352d4e9076c67839003c7788d8604e24dcded13e0b375af3efaa8cf468517"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "wait-timeout"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]

Cargo.toml (0 → 247)

diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..869bd51
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "clean-url"
+version = "1.0.0"
+edition = "2024"
+
+[dependencies]
+clap = { version = "4.5.60", features = ["derive"] }
+urlencoding = "1.0.0"
+urlparse = "0.7.3"
+
+[dev-dependencies]
+anyhow = "1.0.104"
+assert_cmd = "2"
+predicates = "3"

LICENSE (0 → 1053)

diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..e732212
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2026 Alex Chan
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the "Software"),
+to deal in the Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.

README.md (0 → 1368)

diff --git a/README.md b/README.md
new file mode 100644
index 0000000..907484e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,35 @@
+# clean-url
+
+clean-url is a command-line tool for cleaning URLs.
+
+I have text expansion macros that insert the URLs from my frontmost browser window, and this tool cleans up the URL before insertion.
+It removes tracking and referral parameters, unwanted URL suffixes, and generally de-junks URLs.
+
+## Installation
+
+You need Rust installed; I recommend using [Rustup].
+Then clone this repository and compile the code:
+
+```console
+$ git clone "git://alexwlchan.net/projects/clean-url.git"
+$ cd clean-url
+$ cargo install --path .
+```
+
+[Rustup]: https://rustup.rs/
+
+## Usage
+
+clean-url takes a single argument: the URL to be cleaned.
+For example:
+
+```console
+$ clean-url "https://www.etsy.com/uk/listing/689034321/pride-flag-planets-complete-set-cross?ls=s&ga_order=most_relevant&ga_search_type=all&ga_view_type=gallery&ga_search_query=mathysphere+pride+planets&ref=sr_gallery-1-3&dd=1&content_source=750fbda2-0f23-409a-b29d-9714dc4e0ba3%253ALTcadfd6ad1b51d9f2c43bf9fc61aa37ee3ee5cc68&organic_search_click=1&logging_key=750fbda2-0f23-409a-b29d-9714dc4e0ba3%3ALTcadfd6ad1b51d9f2c43bf9fc61aa37ee3ee5cc68"
+https://www.etsy.com/uk/listing/689034321/pride-flag-planets-complete-set-cross⏎
+```
+
+Note: output is printed without a trailing new line, so it integrates cleanly with my text expansion macros.
+
+## License
+
+This project is shared under the [MIT license](./LICENSE).

src/clean_url.rs (0 → 30623)

diff --git a/src/clean_url.rs b/src/clean_url.rs
new file mode 100644
index 0000000..61b6fa4
--- /dev/null
+++ b/src/clean_url.rs
@@ -0,0 +1,737 @@
+use urlencoding::encode as urlencode;
+use urlparse::{Query, Url, parse_qs, urlparse, urlunparse};
+
+fn partial_urlencode(value: &str) -> String {
+    // The urlencode from the urlencoding library goes further than I like,
+    // and also URL encodes ASCII digits. Reverse that stuff.
+    urlencode(value)
+        .replace("%30", "0")
+        .replace("%31", "1")
+        .replace("%32", "2")
+        .replace("%33", "3")
+        .replace("%34", "4")
+        .replace("%35", "5")
+        .replace("%36", "6")
+        .replace("%37", "7")
+        .replace("%38", "8")
+        .replace("%39", "9")
+}
+
+/// Re-encode a query string for Rust
+fn encode_querystring(query: Query) -> Option<String> {
+    let mut query_components: Vec<String> = vec![];
+    for (key, value) in query {
+        for v in value.iter() {
+            query_components.push(format!("{}={}", key, partial_urlencode(v)));
+        }
+    }
+    if query_components.len() > 0 {
+        Some(query_components.join("&"))
+    } else {
+        None
+    }
+}
+
+/// Strip tracking junk and URL suffixes.
+pub fn clean_url(url: &str) -> String {
+    let mut parsed_url = urlparse(url);
+
+    // Always get the desktop version of Twitter URLs
+    if parsed_url.netloc == "mobile.twitter.com" {
+        parsed_url.netloc = String::from("twitter.com");
+    }
+
+    // Always get the non-mobile version of nytimes.com URLs
+    if parsed_url.netloc == "mobile.nytimes.com" {
+        parsed_url.netloc = String::from("nytimes.com");
+    }
+
+    // Remove any tracking junk from Amazon URLs so they're not a
+    // ridiculous length
+    if (parsed_url.netloc == "www.amazon.co.uk") || (parsed_url.netloc == "smile.amazon.co.uk") {
+        parsed_url.query = None;
+        let mut new_path = String::from("");
+        for component in parsed_url.path.split("ref=") {
+            new_path = String::from(component);
+            break;
+        }
+        parsed_url.path = new_path;
+    }
+
+    // Strip tracking junk from Medium, Mashable and Buzzfeed
+    if (parsed_url.netloc == "medium.com")
+        || (parsed_url.netloc == "www.buzzfeed.com")
+        || (parsed_url.netloc == "mashable.com")
+    {
+        parsed_url.fragment = None;
+    }
+
+    // Strip tracking junk from TikTok URLs
+    if parsed_url.netloc == "www.tiktok.com" {
+        parsed_url.query = None;
+    }
+
+    // Remove '#notes' from Tumblr URLs
+    if parsed_url.netloc.ends_with("tumblr.com") {
+        remove_query_params(
+            &mut parsed_url,
+            &["_branch_match_id", "_branch_referrer", "source"],
+        );
+
+        parsed_url.fragment = match parsed_url.fragment {
+            Some(fragment) => {
+                if fragment == "notes" {
+                    None
+                } else {
+                    Some(fragment)
+                }
+            }
+            None => None,
+        };
+    }
+
+    // Remove &feature=youtu.be from YouTube URLs
+    if parsed_url.netloc.ends_with("youtube.com") {
+        remove_query_params(&mut parsed_url, &["app", "feature"]);
+    }
+
+    // Remove any tracking parameters which:
+    //
+    //    * Come from UTM (Urchin Tracking Module)
+    //    * Come from Cloudflare tracking
+    //    * Come from HubSpot
+    //
+    parsed_url.query = match parsed_url.query {
+        Some(qs) => {
+            let mut query = parse_qs(&qs);
+            let ignore_keys: Vec<_> = query
+                .keys()
+                .filter(|key| {
+                    key.starts_with("utm_") || key.starts_with("__cf") || key.starts_with("hsa")
+                })
+                .map(|k| k.clone())
+                .collect();
+            for key in ignore_keys {
+                query.remove(&key);
+            }
+            encode_querystring(query)
+        }
+        None => None,
+    };
+
+    // Tidy up the query and anchor links in modules on docs.python.org
+    if parsed_url.netloc == "docs.python.org" {
+        // If this is a module page, scrap any module- fragment.
+        parsed_url.fragment = match parsed_url.fragment {
+            Some(fragment) => {
+                if fragment.starts_with("module-") {
+                    None
+                } else {
+                    Some(fragment)
+                }
+            }
+            None => None,
+        };
+
+        // Scrap the highlight
+        remove_query_param(&mut parsed_url, "highlight");
+    }
+
+    // If I'm on a GitHub pull request and looking at the files tab,
+    // link to the top of the pull request.
+    if parsed_url.netloc == "github.com" {
+        if parsed_url.path.ends_with("/files") {
+            parsed_url.path = parsed_url.path.replace("/files", "");
+        }
+    }
+
+    // Remove tracking query parameters from telegraph.co.uk URLs
+    // https://github.com/alexwlchan/safari.rs/issues/48
+    if parsed_url.netloc == "www.telegraph.co.uk" {
+        remove_query_param(&mut parsed_url, "WT.mc_id");
+    }
+
+    // Remove tracking parameters from Etsy URLs.
+    if parsed_url.netloc == "www.etsy.com" {
+        remove_query_params(
+            &mut parsed_url,
+            &[
+                "awc",
+                "click_key",
+                "click_sum",
+                "content_source",
+                "crt",
+                "dd",
+                "frs",
+                "logging_key",
+                "ls",
+                "pro",
+                "source",
+                "sts",
+            ],
+        );
+
+        parsed_url.query = match parsed_url.query {
+            Some(qs) => {
+                let mut query = parse_qs(&qs);
+                let utm_keys: Vec<_> = query
+                    .keys()
+                    .filter(|key| {
+                        key.starts_with("ga_")
+                            || key.starts_with("ref")
+                            || key.starts_with("organic_search_click")
+                    })
+                    .map(|k| k.clone())
+                    .collect();
+                for key in utm_keys {
+                    query.remove(&key);
+                }
+                encode_querystring(query)
+            }
+            None => None,
+        };
+    }
+
+    // Un-mobile-ify blogspot links.
+    if parsed_url.netloc.ends_with("blogspot.com") {
+        remove_query_param(&mut parsed_url, "m");
+    }
+
+    // Un-mobile-ify blogspot links.
+    if parsed_url.netloc == "www.redbubble.com" {
+        remove_query_params(&mut parsed_url, &["asc", "ref"]);
+    }
+
+    // Always remove the _ga Google Analytics tracking parameter.
+    remove_query_param(&mut parsed_url, "_ga");
+
+    // Always remove the Google Click ID tracking parameters.
+    remove_query_param(&mut parsed_url, "gclid");
+
+    // Remove tracking parameters from stacks.wellcomecollection.org URLs,
+    // which are from Medium.
+    if parsed_url.netloc == "stacks.wellcomecollection.org" {
+        remove_query_param(&mut parsed_url, "source");
+    }
+
+    // Remove tracking parameters from Wordery URLs.
+    if parsed_url.netloc == "wordery.com" {
+        remove_query_param(&mut parsed_url, "cTrk");
+    }
+
+    // Remove sharing/referral parameter from Twitter URLs.
+    if parsed_url.netloc == "twitter.com" {
+        remove_query_params(&mut parsed_url, &["cxt", "ref_src", "ref_url", "s"]);
+    }
+
+    // Remove the scheme/host from localhost:5757 links, which is the
+    // local instance of my website. I just want the path/query.
+    if parsed_url.netloc == "localhost:5757" {
+        parsed_url.netloc = "".to_string();
+        parsed_url.scheme = "".to_string();
+        return urlunparse(parsed_url).replace("://", "");
+    }
+
+    // Convert links to questions on Stack Overflow to insert my sharing
+    // link for the Announcer badge.
+    // This code is autogenerated by the `se_referral_autogen.py` script.
+    fix_se_referral(&mut parsed_url, "academia.stackexchange.com", "7658");
+    fix_se_referral(&mut parsed_url, "anime.stackexchange.com", "7674");
+    fix_se_referral(&mut parsed_url, "apple.stackexchange.com", "14295");
+    fix_se_referral(&mut parsed_url, "area51.stackexchange.com", "47881");
+    fix_se_referral(&mut parsed_url, "askubuntu.com", "265738");
+    fix_se_referral(&mut parsed_url, "aviation.stackexchange.com", "1372");
+    fix_se_referral(&mut parsed_url, "bicycles.stackexchange.com", "17362");
+    fix_se_referral(&mut parsed_url, "biology.stackexchange.com", "16115");
+    fix_se_referral(&mut parsed_url, "bricks.stackexchange.com", "445");
+    fix_se_referral(&mut parsed_url, "chemistry.stackexchange.com", "5443");
+    fix_se_referral(&mut parsed_url, "chess.stackexchange.com", "2564");
+    fix_se_referral(&mut parsed_url, "christianity.stackexchange.com", "10196");
+    fix_se_referral(&mut parsed_url, "codegolf.stackexchange.com", "13285");
+    fix_se_referral(&mut parsed_url, "codereview.stackexchange.com", "36525");
+    fix_se_referral(&mut parsed_url, "cogsci.stackexchange.com", "7973");
+    fix_se_referral(
+        &mut parsed_url,
+        "communitybuilding.stackexchange.com",
+        "372",
+    );
+    fix_se_referral(&mut parsed_url, "cooking.stackexchange.com", "25134");
+    fix_se_referral(&mut parsed_url, "crypto.stackexchange.com", "1185");
+    fix_se_referral(&mut parsed_url, "diy.stackexchange.com", "25263");
+    fix_se_referral(&mut parsed_url, "dsp.stackexchange.com", "8360");
+    fix_se_referral(&mut parsed_url, "earthscience.stackexchange.com", "352");
+    fix_se_referral(&mut parsed_url, "elementaryos.stackexchange.com", "3586");
+    fix_se_referral(&mut parsed_url, "english.stackexchange.com", "22597");
+    fix_se_referral(&mut parsed_url, "gaming.stackexchange.com", "73524");
+    fix_se_referral(&mut parsed_url, "gardening.stackexchange.com", "813");
+    fix_se_referral(&mut parsed_url, "gis.stackexchange.com", "26054");
+    fix_se_referral(&mut parsed_url, "graphicdesign.stackexchange.com", "19347");
+    fix_se_referral(&mut parsed_url, "law.stackexchange.com", "2488");
+    fix_se_referral(&mut parsed_url, "lifehacks.stackexchange.com", "11245");
+    fix_se_referral(&mut parsed_url, "linguistics.stackexchange.com", "2722");
+    fix_se_referral(&mut parsed_url, "math.stackexchange.com", "24160");
+    fix_se_referral(&mut parsed_url, "matheducators.stackexchange.com", "661");
+    fix_se_referral(&mut parsed_url, "mathematica.stackexchange.com", "5190");
+    fix_se_referral(&mut parsed_url, "mathoverflow.net", "38734");
+    fix_se_referral(&mut parsed_url, "mechanics.stackexchange.com", "14629");
+    fix_se_referral(&mut parsed_url, "meta.stackexchange.com", "226928");
+    fix_se_referral(&mut parsed_url, "money.stackexchange.com", "13518");
+    fix_se_referral(&mut parsed_url, "movies.stackexchange.com", "9285");
+    fix_se_referral(
+        &mut parsed_url,
+        "networkengineering.stackexchange.com",
+        "16668",
+    );
+    fix_se_referral(&mut parsed_url, "opensource.stackexchange.com", "2909");
+    fix_se_referral(&mut parsed_url, "parenting.stackexchange.com", "14304");
+    fix_se_referral(&mut parsed_url, "patents.stackexchange.com", "3882");
+    fix_se_referral(&mut parsed_url, "philosophy.stackexchange.com", "16838");
+    fix_se_referral(&mut parsed_url, "photo.stackexchange.com", "29700");
+    fix_se_referral(&mut parsed_url, "physics.stackexchange.com", "38614");
+    fix_se_referral(&mut parsed_url, "politics.stackexchange.com", "5590");
+    fix_se_referral(&mut parsed_url, "productivity.stackexchange.com", "1990");
+    fix_se_referral(&mut parsed_url, "puzzling.stackexchange.com", "4692");
+    fix_se_referral(&mut parsed_url, "rpg.stackexchange.com", "22823");
+    fix_se_referral(&mut parsed_url, "salesforce.stackexchange.com", "36215");
+    fix_se_referral(&mut parsed_url, "scifi.stackexchange.com", "3567");
+    fix_se_referral(&mut parsed_url, "security.stackexchange.com", "9814");
+    fix_se_referral(&mut parsed_url, "serverfault.com", "206273");
+    fix_se_referral(&mut parsed_url, "skeptics.stackexchange.com", "5416");
+    fix_se_referral(
+        &mut parsed_url,
+        "softwareengineering.stackexchange.com",
+        "94977",
+    );
+    fix_se_referral(&mut parsed_url, "space.stackexchange.com", "1003");
+    fix_se_referral(&mut parsed_url, "sqa.stackexchange.com", "7301");
+    fix_se_referral(&mut parsed_url, "stackapps.com", "21515");
+    fix_se_referral(&mut parsed_url, "stackoverflow.com", "1558022");
+    fix_se_referral(&mut parsed_url, "stats.stackexchange.com", "32450");
+    fix_se_referral(&mut parsed_url, "superuser.com", "243137");
+    fix_se_referral(&mut parsed_url, "tex.stackexchange.com", "9668");
+    fix_se_referral(&mut parsed_url, "travel.stackexchange.com", "12415");
+    fix_se_referral(&mut parsed_url, "unix.stackexchange.com", "43183");
+    fix_se_referral(&mut parsed_url, "ux.stackexchange.com", "9976");
+    fix_se_referral(&mut parsed_url, "webapps.stackexchange.com", "45296");
+    fix_se_referral(&mut parsed_url, "webmasters.stackexchange.com", "35749");
+    fix_se_referral(&mut parsed_url, "workplace.stackexchange.com", "14106");
+    fix_se_referral(&mut parsed_url, "worldbuilding.stackexchange.com", "2575");
+    fix_se_referral(&mut parsed_url, "writers.stackexchange.com", "11018");
+
+    urlunparse(parsed_url)
+}
+
+/// Remove a query parameter from a URL.
+fn remove_query_param(parsed_url: &mut Url, query_param: &str) {
+    remove_query_params(parsed_url, &[query_param]);
+}
+
+/// Remove multiple query parameters from a URL.
+fn remove_query_params(parsed_url: &mut Url, query_params: &[&str]) {
+    parsed_url.query = match parsed_url.query {
+        Some(ref qs) => {
+            let mut query = parse_qs(qs);
+            for param in query_params {
+                query.remove(*param);
+            }
+            encode_querystring(query)
+        }
+        None => None,
+    };
+}
+
+/// Turn a URL into a Stack Exchange referral link.
+///
+/// - `parsed_url` - the `Url` structure returned by urlparse
+/// - `hostname` - hostname of the SE site in question
+/// - `user_id` - your user ID on the SE site
+///
+fn fix_se_referral(parsed_url: &mut Url, hostname: &str, user_id: &str) {
+    if parsed_url.netloc != hostname {
+        return;
+    }
+
+    // A question URL is of the form
+    //
+    //    http://stackoverflow.com/questions/:question_id/:question_title
+    //
+    if !parsed_url.path.starts_with("/questions") {
+        return;
+    }
+
+    // Take a copy of the original path to get around ownership rules
+    let original_path = parsed_url.path.to_owned();
+
+    let new_path = match original_path.split("/").nth(2) {
+        Some(path_component) => {
+            // Check it's a number
+            match path_component.parse::<i32>() {
+                Ok(q_id) => {
+                    // Check if there's an answer fragment
+                    match parsed_url.fragment.to_owned() {
+                        Some(fragment) => match fragment.parse::<i32>() {
+                            Ok(ans_id) => Some(format!("/a/{}/{}", ans_id, user_id)),
+                            Err(_) => None,
+                        },
+                        None => Some(format!("/q/{}/{}", q_id, user_id)),
+                    }
+                }
+                Err(_) => None,
+            }
+        }
+        None => None,
+    };
+
+    // If we got something interesting, update the URL.
+    match new_path {
+        Some(p) => {
+            parsed_url.path = p;
+            parsed_url.fragment = None;
+        }
+        None => (),
+    };
+}
+
+#[cfg(test)]
+mod clean_url_tests {
+    use crate::clean_url::clean_url;
+
+    macro_rules! clean_url_tests {
+        ($($name:ident: $value:expr,)*) => {
+            $(
+                #[test]
+                fn $name() {
+                    let (input, expected) = $value;
+                    assert_eq!(expected, clean_url(input));
+                }
+            )*
+        }
+    }
+
+    clean_url_tests! {
+        etsy_bear: (
+                "https://www.etsy.com/uk/listing/721435625/bear-cross-stitch-pattern-pdf-bear?ls=s&ga_order=most_relevant&ga_search_type=all&ga_view_type=gallery&ga_search_query=bear+cross+stitch+pattern&ref=sr_gallery-1-12&sts=1&dd=1&content_source=00ca7d91-b721-4f6f-8b8e-128c10da15f7%253ALT6e4e75eacc959cc45062f0ebc0d7ede920b198fe&organic_search_click=1&logging_key=00ca7d91-b721-4f6f-8b8e-128c10da15f7%3ALT6e4e75eacc959cc45062f0ebc0d7ede920b198fe",
+                "https://www.etsy.com/uk/listing/721435625/bear-cross-stitch-pattern-pdf-bear"
+        ),
+
+        etsy_readme: (
+            "https://www.etsy.com/uk/listing/689034321/pride-flag-planets-complete-set-cross?ls=s&ga_order=most_relevant&ga_search_type=all&ga_view_type=gallery&ga_search_query=mathysphere+pride+planets&ref=sr_gallery-1-3&dd=1&content_source=750fbda2-0f23-409a-b29d-9714dc4e0ba3%253ALTcadfd6ad1b51d9f2c43bf9fc61aa37ee3ee5cc68&organic_search_click=1&logging_key=750fbda2-0f23-409a-b29d-9714dc4e0ba3%3ALTcadfd6ad1b51d9f2c43bf9fc61aa37ee3ee5cc68",
+            "https://www.etsy.com/uk/listing/689034321/pride-flag-planets-complete-set-cross"
+        ),
+
+        self_localhost_homepage: (
+            "http://localhost:5757/",
+            "/"
+        ),
+
+        self_localhost_page: (
+            "http://localhost:5757/book-reviews/",
+            "/book-reviews/"
+        ),
+
+        twitter_mobile: (
+            "https://mobile.twitter.com/Breaking911/status/822589852596191235",
+            "https://twitter.com/Breaking911/status/822589852596191235"
+        ),
+
+        regular_twitter: (
+            "https://twitter.com/WholesomeMeme/status/846421658835537921",
+            "https://twitter.com/WholesomeMeme/status/846421658835537921"
+        ),
+
+        twitter_with_tracking_param: (
+            "https://twitter.com/b0rk/status/1627763836479479811?cxt=HHwWhoCxubil_ZYtAAAA",
+            "https://twitter.com/b0rk/status/1627763836479479811"
+        ),
+
+        amazon_product: (
+            "https://www.amazon.co.uk/dp/B01DFKBL68/ref=gw_aucc_comb_AB_clean_2?pf_rd_r=N1MF2PWADRHS55427ETG&pf_rd_p=c11a2c11-c670-46ff-87a1-c1eef4fad652",
+            "https://www.amazon.co.uk/dp/B01DFKBL68/"
+        ),
+
+        amazon_smile_product: (
+            "https://smile.amazon.co.uk/dp/B01DFKBL68/ref=gw_aucc_comb_AB_clean_2?pf_rd_r=N1MF2PWADRHS55427ETG&pf_rd_p=c11a2c11-c670-46ff-87a1-c1eef4fad652",
+            "https://smile.amazon.co.uk/dp/B01DFKBL68/"
+        ),
+
+        medium_with_tracking: (
+            "https://medium.com/@anildash/forget-why-its-time-to-get-to-work-c49ac5f0da20#.sjyskxdsz",
+            "https://medium.com/@anildash/forget-why-its-time-to-get-to-work-c49ac5f0da20"
+        ),
+
+        tumblr_with_notes: (
+            "http://azurelunatic.tumblr.com/post/155525051123/things-about-hufflepuffs-539#notes",
+            "http://azurelunatic.tumblr.com/post/155525051123/things-about-hufflepuffs-539"
+        ),
+
+        tumblr_with_tracking: (
+            "https://www.tumblr.com/tightleish/709085976781324289?_branch_referrer=H4sIAAAAAAAAAwXBAQqAIAwAwB8509Vmv6kQpyhFzvD53Ynq03eAQ42OdtbXXHcDzUm0xtwFyAbLa6CNePEOHQeYOGL6Cpbp6Qe%2F0rcpQAAAAA%3D%3D&source=share&_branch_match_id=1137383385327673172",
+            "https://www.tumblr.com/tightleish/709085976781324289"
+        ),
+
+        buzzfeed_with_tracking: (
+            "https://www.buzzfeed.com/katienotopoulos/the-end-of-apple-man#.biqmkzz8kn",
+            "https://www.buzzfeed.com/katienotopoulos/the-end-of-apple-man"
+        ),
+
+        mashable_with_tracking: (
+            "http://mashable.com/2016/03/21/apple-liam-recycling-robot/#b9y4lv3m4qqX",
+            "http://mashable.com/2016/03/21/apple-liam-recycling-robot/"
+        ),
+
+        youtube_plain_video: (
+            "https://www.youtube.com/watch?v=zB4I68XVPzQ",
+            "https://www.youtube.com/watch?v=zB4I68XVPzQ",
+        ),
+
+        youtube_with_timestamp: (
+            "https://www.youtube.com/watch?v=D68cUzqcTrg&feature=youtu.be#t=1m",
+            "https://www.youtube.com/watch?v=D68cUzqcTrg#t=1m",
+        ),
+
+        youtube_with_trailing_feature: (
+            "https://www.youtube.com/watch?v=PbJqNa0_Oz0&feature=youtu.be",
+            "https://www.youtube.com/watch?v=PbJqNa0_Oz0",
+        ),
+
+        youtube_with_leading_feature: (
+            "https://www.youtube.com/watch?feature=youtu.be&v=oPo4n9tBPsk",
+            "https://www.youtube.com/watch?v=oPo4n9tBPsk",
+        ),
+
+        youtube_with_only_feature: (
+            "https://www.youtube.com/watch?feature=youtu.be",
+            "https://www.youtube.com/watch",
+        ),
+
+        youtube_channel: (
+            "https://www.youtube.com/user/TheQIElves",
+            "https://www.youtube.com/user/TheQIElves"
+        ),
+
+        youtube_with_app: (
+            "https://www.youtube.com/watch?app=desktop&v=51HbzsFhh04",
+            "https://www.youtube.com/watch?v=51HbzsFhh04"
+        ),
+
+        single_utm_tracker: (
+            "https://example.com?utm_medium=social",
+            "https://example.com",
+        ),
+
+        multiple_utm_tracker: (
+            "https://example.com?utm_medium=social&utm_source=twitter",
+            "https://example.com",
+        ),
+
+        multiple_utm_tracker_with_others: (
+            "https://example.com?utm_medium=social&foo=bar&utm_source=twitter",
+            "https://example.com?foo=bar",
+        ),
+
+        url_with_spaces: (
+            "https://example.com?foo=bar%20baz",
+            "https://example.com?foo=bar%20baz"
+        ),
+
+        url_with_numerals: (
+            "https://example.com?foo=bar0baz",
+            "https://example.com?foo=bar0baz"
+        ),
+
+        python_docs_bare: (
+            "https://docs.python.org/3.5/library/subprocess.html",
+            "https://docs.python.org/3.5/library/subprocess.html"
+        ),
+
+        python_docs_with_anchor: (
+            "https://docs.python.org/3.5/library/subprocess.html#subprocess.run",
+            "https://docs.python.org/3.5/library/subprocess.html#subprocess.run"
+        ),
+
+        python_docs_with_highlight: (
+            "https://docs.python.org/3.5/library/subprocess.html?highlight=subprocess",
+            "https://docs.python.org/3.5/library/subprocess.html"
+        ),
+
+        python_docs_with_highlight_anchor: (
+            "https://docs.python.org/3.5/library/subprocess.html?highlight=subprocess#subprocess.run",
+            "https://docs.python.org/3.5/library/subprocess.html#subprocess.run"
+        ),
+
+        python_docs_with_highlight_module_anchor: (
+            "https://docs.python.org/3.5/library/subprocess.html?highlight=subprocess#module-subprocess",
+            "https://docs.python.org/3.5/library/subprocess.html"
+        ),
+
+        stack_overflow_non_question: (
+            "http://stackoverflow.com/questions/tagged/html+regex",
+            "http://stackoverflow.com/questions/tagged/html+regex"
+        ),
+
+        stack_overflow_question: (
+            "http://stackoverflow.com/questions/1732348/regex-match-open-tags-except-xhtml-self-contained-tags",
+            "http://stackoverflow.com/q/1732348/1558022"
+        ),
+
+        stack_overflow_answer: (
+            "http://stackoverflow.com/questions/82831/how-do-i-check-whether-a-file-exists-using-python#82852",
+            "http://stackoverflow.com/a/82852/1558022"
+        ),
+
+        sff_se_non_question: (
+            "https://scifi.stackexchange.com/review",
+            "https://scifi.stackexchange.com/review"
+        ),
+
+        sff_se_question: (
+            "https://scifi.stackexchange.com/questions/58980/how-did-lupin-forget-there-was-a-full-moon",
+            "https://scifi.stackexchange.com/q/58980/3567"
+        ),
+
+        sff_se_answer: (
+            "https://scifi.stackexchange.com/questions/39201/which-owls-did-fred-and-george-weasley-achieve/39218#39218",
+            "https://scifi.stackexchange.com/a/39218/3567"
+        ),
+
+        telegraph_bare: (
+            "http://www.telegraph.co.uk/news/2017/06/09/ruth-davidson-planning-scottish-tory-breakaway-challenges-theresa/",
+            "http://www.telegraph.co.uk/news/2017/06/09/ruth-davidson-planning-scottish-tory-breakaway-challenges-theresa/"
+        ),
+
+        strip_telegraph_tracking: (
+            "http://www.telegraph.co.uk/news/2017/06/09/ruth-davidson-planning-scottish-tory-breakaway-challenges-theresa/?WT.mc_id=tmg_share_tw",
+            "http://www.telegraph.co.uk/news/2017/06/09/ruth-davidson-planning-scottish-tory-breakaway-challenges-theresa/"
+        ),
+
+        se_comments: (
+            "https://stackoverflow.com/questions/406230/regular-expression-to-match-a-line-that-doesnt-contain-a-word#comment9209422_406230",
+            "https://stackoverflow.com/questions/406230/regular-expression-to-match-a-line-that-doesnt-contain-a-word#comment9209422_406230"
+        ),
+
+        ga_tracking_parameter: (
+            "https://example.org?_ga=1234",
+            "https://example.org"
+        ),
+
+        ga_tracking_parameter_and_others: (
+            "https://example.org?_ga=1234&foo=bar",
+            "https://example.org?foo=bar"
+        ),
+
+        nytimes_mobile_url: (
+            "https://mobile.nytimes.com/2017/02/24/style/modern-love-when-your-greatest-romance-is-friendship.html",
+            "https://nytimes.com/2017/02/24/style/modern-love-when-your-greatest-romance-is-friendship.html"
+        ),
+
+        nytimes_non_mobile_url: (
+            "https://nytimes.com/2017/02/24/style/modern-love-when-your-greatest-romance-is-friendship.html",
+            "https://nytimes.com/2017/02/24/style/modern-love-when-your-greatest-romance-is-friendship.html"
+        ),
+
+        github_pr: (
+            "https://github.com/wellcometrust/platform/pull/1892",
+            "https://github.com/wellcometrust/platform/pull/1892"
+        ),
+
+        github_pr_with_files: (
+            "https://github.com/wellcometrust/platform/pull/1892/files",
+            "https://github.com/wellcometrust/platform/pull/1892"
+        ),
+
+        etsy_link: (
+            "https://www.etsy.com/uk/listing/473409127/space-sampler-cross-stitch-pattern-pdf?ga_order=most_relevant&ref=sr_gallery-1-3&ga_view_type=gallery&organic_search_click=1&ga_search_type=all&ga_search_query=space%20sampler",
+            "https://www.etsy.com/uk/listing/473409127/space-sampler-cross-stitch-pattern-pdf"
+        ),
+
+        etsy_section_link: (
+            "https://www.etsy.com/uk/shop/Vitraaze?source=aw&section_id=17807678&awc=6091_1577546787_d9f1b918042bbd02fe406d61dae715e7",
+            "https://www.etsy.com/uk/shop/Vitraaze?section_id=17807678"
+        ),
+
+        etsy_pro_link: (
+            "https://www.etsy.com/uk/listing/704478659/gothic-cathedral-window-ornate-cross?pro=1",
+            "https://www.etsy.com/uk/listing/704478659/gothic-cathedral-window-ornate-cross"
+        ),
+
+        etsy_link_with_tracking: (
+            "https://www.etsy.com/uk/listing/731582894/personalised-song-lyric-or-poetry?frs=1",
+            "https://www.etsy.com/uk/listing/731582894/personalised-song-lyric-or-poetry",
+        ),
+
+        etsy_link_with_tmore_tracking: (
+            "https://www.etsy.com/uk/listing/1487193769/progress-goose-pdf-gay-pride-month-cross?click_sum=15f8aef5&click_key=d478a4a346e8318cc55887564ddfef8f5687fb60%3A1487193769&crt=1",
+            "https://www.etsy.com/uk/listing/1487193769/progress-goose-pdf-gay-pride-month-cross",
+        ),
+
+        blogspot: (
+            "https://example.blogspot.com/2020/01/01/my_first_post.html",
+            "https://example.blogspot.com/2020/01/01/my_first_post.html"
+        ),
+
+        blogspot_mobile_link: (
+            "https://example.blogspot.com/2020/01/01/my_first_post.html?m=1",
+            "https://example.blogspot.com/2020/01/01/my_first_post.html"
+        ),
+
+        redbubble_plain: (
+            "https://www.redbubble.com/people/m1sfire/works/42386806-pixel-pride-genderfluid",
+            "https://www.redbubble.com/people/m1sfire/works/42386806-pixel-pride-genderfluid"
+        ),
+
+        redbubble_referrer: (
+            "https://www.redbubble.com/people/m1sfire/works/42386806-pixel-pride-genderfluid?ref=recent-owner",
+            "https://www.redbubble.com/people/m1sfire/works/42386806-pixel-pride-genderfluid"
+        ),
+
+        redbubble_asc: (
+            "https://www.redbubble.com/people/m1sfire/works/42386806-pixel-pride-genderfluid?asc=u",
+            "https://www.redbubble.com/people/m1sfire/works/42386806-pixel-pride-genderfluid"
+        ),
+
+        stacks_medium_referrer: (
+            "https://stacks.wellcomecollection.org/a-sprinkling-of-azure-6cef6e150fb2?source=collection_home---4------0———————————",
+            "https://stacks.wellcomecollection.org/a-sprinkling-of-azure-6cef6e150fb2"
+        ),
+
+        wordery_with_tracking_param: (
+            "https://wordery.com/digging-for-words-angela-burke-kunkel-9781984892638?cTrk=MTgwMTkyNTkxfDVmYjgzYzU5YzQ4Mjc6MToxOjVmYjgzYzE4ZTdhNzQ4LjczMzU3NDQxOjVhMzBiMGYx",
+            "https://wordery.com/digging-for-words-angela-burke-kunkel-9781984892638"
+        ),
+
+        twitter_with_share_link: (
+            "https://twitter.com/ScotRail/status/1355928580895731713?s=20",
+            "https://twitter.com/ScotRail/status/1355928580895731713"
+        ),
+
+        twitter_with_ref: (
+            "https://twitter.com/thingskatedid/status/1386077306381242371?ref_url=https%3A%2F%2Fwww.benweintraub.com%2F&ref_src=twsrc%5Etfw%7Ctwcamp%5Etweetembed%7Ctwterm%5E1386077306381242371%7Ctwgr%5E13dd6945ce97e163ca6ca5d28b54fc562b161b2f%7Ctwcon%5Es1_",
+            "https://twitter.com/thingskatedid/status/1386077306381242371"
+        ),
+
+        twitter_with_cxt: (
+            "https://twitter.com/b0rk/status/1627763836479479811?cxt=HHwWhoCxubil_ZYtAAAA",
+            "https://twitter.com/b0rk/status/1627763836479479811"
+        ),
+
+        url_with_cloudflare_query_param: (
+            "https://example.org/page?__cf_chl_jschl_tk__=1",
+            "https://example.org/page"
+        ),
+
+        url_with_hubspot_query_param: (
+            "https://imgix.com/pricing?hsa_grp=131223639264&hsa_acc=8534109361&gclid=CjwKCAiA-8SdBhBGEiwAWdgtcHVTpIo3PdadyKLfI4Wv3CdUUhcRISS0QNoitcBtZeJxiCJ_ZKowvhoCg68QAvD_BwE&hsa_src=g&hsa_cam=13763501966&hsa_mt=b&hsa_net=adwords&hsa_ad=598338680789&hsa_tgt=kwd-340899810185&hsa_ver=3&hsa_kw=imgix%20pricing",
+            "https://imgix.com/pricing"
+        ),
+
+        url_with_google_click_id_query_param: (
+            "https://imgix.com/pricing?gclid=CjwKCAiA-8SdBhBGEiwAWdgtcHVTpIo3PdadyKLfI4Wv3CdUUhcRISS0QNoitcBtZeJxiCJ_ZKowvhoCg68QAvD_BwE",
+            "https://imgix.com/pricing"
+        ),
+
+        tiktok_with_tracking_junk: (
+            "https://www.tiktok.com/@example/video/1234567890?sec_user_id=ABCDEFGHIJ&u_code=dgfffl6mjl6be3&share_app_id=1233&timestamp=1631110682",
+            "https://www.tiktok.com/@example/video/1234567890"
+        ),
+    }
+}

src/main.rs (0 → 1633)

diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..351cd23
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,54 @@
+#![deny(warnings)]
+
+use clap::Parser;
+
+mod clean_url;
+use crate::clean_url::clean_url;
+
+#[derive(Parser)]
+#[command(version, about = "Clean tracking and unwanted parameters from URLs.", long_about = None)]
+struct Cli {
+    /// URL to clean
+    #[arg()]
+    url: String,
+}
+
+fn main() {
+    let cli = Cli::parse();
+
+    print!("{}", clean_url(&cli.url));
+}
+
+#[cfg(test)]
+mod test_cli {
+    use anyhow::Result;
+    use assert_cmd::Command;
+    use predicates::prelude::*;
+
+    #[test]
+    fn it_prints_the_version() -> Result<()> {
+        // Match strings like `clean-url 1.2.3`
+        let is_version_string = predicate::str::is_match(r"^clean-url [0-9]+\.[0-9]+\.[0-9]+\n$")?;
+
+        Command::cargo_bin("clean-url")?
+            .arg("--version")
+            .assert()
+            .success()
+            .stdout(is_version_string)
+            .stderr("");
+
+        Ok(())
+    }
+
+    #[test]
+    fn it_runs_the_readme_example() -> Result<()> {
+        Command::cargo_bin("clean-url")?
+            .arg("https://www.etsy.com/uk/listing/689034321/pride-flag-planets-complete-set-cross?ls=s&ga_order=most_relevant&ga_search_type=all&ga_view_type=gallery&ga_search_query=mathysphere+pride+planets&ref=sr_gallery-1-3&dd=1&content_source=750fbda2-0f23-409a-b29d-9714dc4e0ba3%253ALTcadfd6ad1b51d9f2c43bf9fc61aa37ee3ee5cc68&organic_search_click=1&logging_key=750fbda2-0f23-409a-b29d-9714dc4e0ba3%3ALTcadfd6ad1b51d9f2c43bf9fc61aa37ee3ee5cc68")
+            .assert()
+            .success()
+            .stdout("https://www.etsy.com/uk/listing/689034321/pride-flag-planets-complete-set-cross")
+            .stderr("");
+
+        Ok(())
+    }
+}