常用正则内含 1 场可玩狩猎

URL 正则:该匹配什么,什么该交给解析器

在文本中查找链接和校验一个 URL 是两件不同的事。本文为两者各给出一个正则,讲解让坏 URL 混过去的陷阱(末尾标点、未转义的点、缺失的锚点),以及何时该把工作交给 new URL() 或 urllib。

Regex Hunter 标志

· 阅读约 5 分钟 · 更新于

/^https?://\S+$/

抓住

  • https://a.io
  • http://x.com/p
  • https://b.dev?q=1

放过

  • ftp://x.com
  • www.x.com
  • http:/x.com

“URL 正则”通常指以下两件事之一:

  1. 在一段文本中查找 URL,比如聊天应用把链接变成可点击的内容。
  2. 校验单个输入,比如个人资料表单里的“网站”字段。

两者需要不同的正则,而第二件事主要应该交给 URL 解析器。本文两者都会讲,还会介绍各自容易踩的陷阱。每个示例都可以交互,修改正则就能看到变化。

快速答案JavaScript
/^(https?)://([^/:?#\s]+)(:\d+)?(/[^?#\s]*)?(\?[^#\s]*)?(#\S*)?$/i

以 http 或 https 开头、带主机名的 URL,后面可以跟端口、路径、查询和片段,不含空格。

匹配

  • https://example.com
  • http://localhost:3000/api?x=1
  • https://example.com/blog#top

不匹配

  • ftp://example.com
  • www.example.com
  • https://exa mple.com

01在文本中查找 URL

最快的写法是一个协议,后面跟着任何非空白字符:

匹配5/5 符合预期
/https?://\S+/

应当匹配3/3

  • Docs: https://regexhunter.com/guide.
  • (see https://example.com/a)
  • <https://example.com>

不应匹配2/2

  • see www.example.com
  • ftp://files.example.com
在沙盒里试试
每一行都匹配了,但看看被高亮的部分。句点、右括号和 > 全都被算进了 URL。

\S+ 会一直抓到下一个空格为止,而在真实的文字里,URL 后面常常紧跟着标点。把它做成链接,读者点开就是 404。

02去掉末尾的标点

解决办法是规定最后一个字符可以是什么。主体部分可以是除空白、尖括号和引号之外的任何字符,而最后一个字符还不能是常见的标点:

匹配4/4 符合预期
/https?://[^\s<>"]*[^\s<>".,;:!?)]/
  • Docs: https://regexhunter.com/guide.
  • (see https://example.com/a)
  • <https://example.com>
  • https://example.com/search?q=regex&page=2
在沙盒里试试
URL 中间仍然可以包含点和冒号,只是不能以它们结尾。

这之所以有效,是因为贪婪的 [^\s<>"]* 会一路匹配到 URL 末尾,然后一次退回一个字符,直到最后的字符类能够匹配。(我们关于贪婪与懒惰量词的文章详细讲解了这个回退过程。)

这里没有完美的规则。像 https://en.wikipedia.org/wiki/Regex_(disambiguation) 这样的维基百科链接确实以 ) 结尾,而这个正则会把它切掉。链接识别库的做法是检查括号是否配对,而这已经超出了一个正则应该承担的逻辑量。

注意,www.example.com 没有被找到,因为它没有协议。如果你也想匹配不带协议的 www. 链接,可以把正则开头改成 (https?://|www\.)。

03校验 URL:各个组成部分

一个网页 URL 最多有六个部分。以 https://regexhunter.com:443/blog/regex-urls?ref=home#top 为例:

部分 示例 必需?
协议 https 是
主机 regexhunter.com 是
端口 :443 否
路径 /blog/regex-urls 否
查询 ?ref=home 否
片段 #top 否

检查每个部分形状的正则如下:

匹配9/9 符合预期
/^(https?)://([^/:?#\s]+)(:\d+)?(/[^?#\s]*)?(\?[^#\s]*)?(#\S*)?$/i

应当匹配4/4

  • https://example.com
  • HTTPS://EXAMPLE.COM
  • http://localhost:3000/api?x=1
  • https://regexhunter.com/blog/regex-urls#top

不应匹配5/5

  • ftp://example.com
  • https://
  • https://exa mple.com
  • https://example.com:port
  • example.com
在沙盒里试试
协议、主机,然后是可选的端口、路径、查询和片段,各自占一个分组。i 修饰符允许大写的 HTTPS://。

逐部分来读:

  1. ^(https?):// 协议,http 或 https,以及 ://
  2. ([^/:?#\s]+) 主机:直到下一个 /、:、? 或 # 之前的所有内容
  3. (:\d+)? 可选的端口,只能是数字
  4. (/[^?#\s]*)? 可选的路径,以 / 开头
  5. (\?[^#\s]*)? 可选的查询,以 ? 开头
  6. (#\S*)?$ 可选的片段,然后是结尾

作为形状检查,它很不错。但它完全不管主机是否合理。https://999.999.999.999 能通过,https://- 也能。你可以继续为主机名、IP 地址、IPv6 方括号、用户名和密码、国际化域名添加规则。确实有人这么做过,结果长达几百个字符。

04让解析器来解析

每种主流语言都自带一个 URL 解析器,它早就了解这些规则。在 JavaScript(浏览器和 Node)中,URL 构造函数遇到无法解析的内容就会抛出异常:

function isHttpUrl(s) {
  try {
    const u = new URL(s);
    return u.protocol === 'http:' || u.protocol === 'https:';
  } catch {
    return false;
  }
}

new URL("https://exa mple.com")、new URL("https://999.999.999.999") 和 new URL("example.com") 都会抛出异常。协议检查很重要:new URL("javascript:alert(1)") 能顺利解析,而你肯定不希望链接里出现它。

各个部分也顺带就能拿到:u.hostname、u.port、u.pathname、u.search、u.hash,以及用 u.searchParams.get("x") 读取单个查询值。返回的 URL 还经过了规范化:HTTPS://Example.COM:443/a/../b 会变成 https://example.com/b。

在 Python 中,urllib.parse.urlsplit 会把 URL 拆成同样的几个部分:

from urllib.parse import urlsplit

u = urlsplit("http://localhost:3000/api?x=1#top")
(u.scheme, u.hostname, u.port, u.path, u.query, u.fragment)
# ('http', 'localhost', 3000, '/api', 'x=1', 'top')

但 urlsplit 只负责拆分,不负责校验,它很少报错。urlsplit("example.com") 返回空的协议和主机,并把 example.com 当作路径;urlsplit("https://exa mple.com") 则若无其事地把 exa mple.com 报告为主机。所以要检查 u.scheme in ("http", "https") 以及 u.netloc 不为空,需要的话再加上你自己的主机规则。

05陷阱:未转义的点

在正则中,. 表示“任意字符”。在域名里忘了加反斜杠,你就放行了仿冒域名:

匹配2/2 符合预期
/^https://www.example.com/
  • https://www.example.com
  • https://wwwxexample.com
在沙盒里试试
两行都匹配。www 和 example 后面的点可以匹配任意字符,所以别人可以注册 wwwxexample.com。

要写成 www\.example\.com。正则里每个字面意义上的点都需要反斜杠。

06陷阱:缺失的结尾

这一点对重定向和允许列表检查很重要,比如“只把用户送回我们自己网站上的 URL”:

匹配2/2 符合预期
/^https://example\.com/
  • https://example.com/login
  • https://example.com.evil.io
在沙盒里试试
点已经转义,开头也已锚定,但没有任何东西规定主机到此为止。第二行是别人的域名。

主机必须在 .com 之后立即结束。要求后面紧跟一个能结束主机的字符,或者字符串结尾:

匹配6/6 符合预期
/^https://example\.com([/?#:]|$)/

应当匹配3/3

  • https://example.com
  • https://example.com/login
  • https://example.com?ref=mail

不应匹配3/3

  • https://example.com.evil.io
  • https://example.company.io
  • https://evil.io/?next=https://example.com
在沙盒里试试
.com 后面必须是 /、?、#、端口前的冒号,或者什么都没有。

对安全检查来说更好的做法是:用 new URL() 或 urlsplit 解析,然后用 === 或 == 比较主机名。精确的字符串比较没有任何容易遗漏的边界情况。

07试一试

每个怪物都是一个网址。无辜者要么协议不对,要么 :// 写坏了,要么有拼写错误,要么根本没有协议:

可玩狩猎标准杆 10

抓住 http 和 https 地址,放过其余的。

目标 · 抓住 0/4

  • https://regexhunter.com目标
  • http://localhost:3000目标
  • https://a.io/x?y=1目标
  • http://example.org/目标

无辜词 · 误抓 0

  • ftp://example.org无辜
  • https:/broken.com无辜
  • example.com无辜
  • mailto:ana@site.com无辜
  • htps://typo.io无辜

输入正则——棋盘会实时更新。

在沙盒里试试
喜欢吗?每天都有新的狩猎。玩今天的狩猎

08检查清单

  • 在文本中查找 URL:用 https?://[^\s<>"]*[^\s<>".,;:!?)],把末尾的标点排除在外。
  • 校验单个 URL:在 JavaScript 中用 new URL() 并检查协议,在 Python 中用 urlsplit 并检查协议和主机。
  • 转义每个字面意义上的点:example\.com。
  • 在允许列表中,要同时锚定主机的开头和结尾,更好的做法是比较解析后的主机名。
  • 如果可能出现大写字母,加上 i 修饰符。HTTPS://EXAMPLE.COM 是合法的 URL。

09继续练习