在C#中解析CSS:提取所有URL

9

我需要从CSS文件中获取所有的URL(url()表达式)。例如:

b { background: url(img0) }
b { background: url("img1") }
b { background: url('img2') }
b { background: url( img3 ) }
b { background: url( "img4" ) }
b { background: url( 'img5' ) }
b { background: url (img6) }
b { background: url ("img7") }
b { background: url ('img8') }
{ background: url('noimg0) }
{ background: url(noimg1') }
/*b { background: url(noimg2) }*/
b { color: url(noimg3) }
b { content: 'url(noimg4)' }
@media screen and (max-width: 1280px) { b { background: url(img9) } }
b { background: url(img10) }

我需要获取所有的 img* URL,但不包括 noimg* URL(无效的语法或属性或在注释内)。
我尝试使用传统的正则表达式。经过一些尝试和错误,我得到了以下代码:
private static IEnumerable<string> ParseUrlsRegex (string source)
{
    var reUrls = new Regex(@"(?nx)
        url \s* \( \s*
            (
                (?! ['""] )
                (?<Url> [^\)]+ )
                (?<! ['""] )
                |
                (?<Quote> ['""] )
                (?<Url> .+? )
                \k<Quote>
            )
        \s* \)");
    return reUrls.Matches(source)
        .Cast<Match>()
        .Select(match => match.Groups["Url"].Value);
}

那是一个疯狂的正则表达式,但它仍然无法正常工作 -- 它匹配了3个无效的 URL(即第2、3和4个)。此外,每个人都会说,使用正则表达式解析复杂语法是错误的。

让我们尝试另一种方法。根据 这个问题,唯一可行的选择是 ExCSS(其他选项要么过于简单,要么已过时)。使用 ExCSS,我得到了以下结果:

    private static IEnumerable<string> ParseUrlsExCss (string source)
    {
        var parser = new StylesheetParser();
        parser.Parse(source);
        return parser.Stylesheet.RuleSets
            .SelectMany(i => i.Declarations)
            .SelectMany(i => i.Expression.Terms)
            .Where(i => i.Type == TermType.Url)
            .Select(i => i.Value);
    }

与正则表达式解决方案不同,此解决方案不列出无效的URL。但它没有列出一些有效的URL!即9和10。看起来这是某些CSS语法已知问题,而且没有办法在不从头开始重写整个库的情况下解决。ANTLR重写似乎已经被放弃
问题:如何从CSS文件中提取所有的URL?(我需要解析任何CSS文件,而不仅仅是上面提供的一个例子。请不要检查“noimg”或假设单行声明。)
注:这不是一个“工具推荐”问题,因为任何解决方案都可以,无论是代码片段、对上述解决方案的修复、库还是其他任何东西;而且我已经清楚地定义了我需要的功能。

1
我试图为这个答案编写一个解析器。可惜CSS规范并没有提供太多帮助(参见http://www.nczonline.net/blog/2011/01/11/the-sorry-state-of-the-css3-specifications/和https://dev59.com/eGw05IYBdhLWcg3w3lkl)。因此,我认为ExCSS可能会漏掉一些有效的项目。 - Daniel Gimenez
这比你想象的更难。还有一个额外的情况不应匹配:在引号字符串内的URL:例如 p[example="...url(link)..."] { color: red }。(请参见:CSS规范。)因此,您不能简单地取出URL - 您必须从头到尾解析CSS文件并正确处理所有带引号的字符串、注释和CSS标记。话虽如此,我相信一个单一的(非平凡的)正则表达式解决方案可以整洁地完成这个任务,但需要使用回调函数。请稍等... - ridgerunner
你有语言选择吗?我会用 Perl 解决这个问题。 - Owen Beresford
9个回答

6

最终成功使用Alba.CsCss,这是我从Mozilla Firefox移植的CSS解析器。

首先,问题包含两个错误

  1. url(img)语法不正确,因为CSS语法中不允许在url(之间添加空格。因此,“img6”,“img7”和“img8”不应返回为URL。

  2. url函数中未关闭引号(url('img))是一个严重的语法错误;Web浏览器(包括Firefox)似乎无法从中恢复,并简单地跳过CSS文件的其余部分。因此,要求解析器返回“img9”和“img10”是不必要的(但如果删除两行有问题的代码,则是必要的)。

有了CsCss,有两种解决方案。

第一个解决方案是仅依赖于标记分析器CssScanner

List<string> uris = new CssLoader().GetUris(source).ToList();

这将返回所有“img” URL(除了上述错误#1中提到的),但也会包括“noimg3”作为属性名称未经检查。
第二种解决方案是正确解析CSS文件。这将最接近浏览器的行为(包括在未关闭引号后停止解析)。
var css = new CssLoader().ParseSheet(source, SheetUri, BaseUri);
List<string> uris = css.AllStyleRules
    .SelectMany(styleRule => styleRule.Declaration.AllData)
    .SelectMany(prop => prop.Value.Unit == CssUnit.List
        ? prop.Value.List : new[] { prop.Value })
    .Where(value => value.Unit == CssUnit.Url)
    .Select(value => value.OriginalUri)
    .ToList();

如果删除这两行有问题的代码,就会返回所有正确的“img” URL。
(LINQ查询很复杂,因为CSS3中的background-image属性可以包含URL列表。)

你在第一点上是正确的,CSS规范中的语法是"url("空格(字符串或urlchar*)空格")"。然而,用户代理不会像规范那样严格,允许有空格也是合理的。 - Daniel Gimenez
虽然正则表达式可以用来解析CSS,但这样做就像使用正则表达式p̟͕̝͞a̪̺ŗ̹̥͕s̹̯̺̗͕̼i̶̠̤̭̳̤̩n̢̞͇̰͖̭g̵̣̹͙̖̥̖͕ ̶͍̼̱͈͎͈̜H̴̰̻̗̭̭T̶͈̫̗̳͇̙̮M͓̗L̘̻̫͙ ̢͉ẉ͖̘̻͟i̧̳̼̥̪̹̟̜t̖h͍͖̰̭ ̷r͜e̜͎̣̦͞g̶̱̯̱̩ͅex̵̙̝̙͈一样,是不被允许的。因此,只有使用解析器的解决方案才应该被接受为正确的。 - Athari
恭喜你们开发出了一个很棒的库! - Click Ok

5

正则表达式是一个非常强大的工具。但当需要更多的灵活性时,我更喜欢编写一些代码。

因此,针对非正则表达式解决方案,我想出了以下方法。请注意,还需要做更多的工作,使此代码更通用以处理任何CSS文件。为此,我也会使用我的文本解析辅助类

IEnumerable<string> GetUrls(string css)
{
    char[] trimChars = new char[] { '\'', '"', ' ', '\t', };

    foreach (var line in css.Split(new string[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries))
    {
        // Extract portion within curly braces (this version assumes all on one line)
        int start = line.IndexOf('{');
        int end = line.IndexOf('}', start + 1);
        if (start < 0 || end < 0)
            continue;
        start++; end--; // Remove braces

        // Get value portion
        start = line.IndexOf(':', start);
        if (start < 0)
            continue;

        // Extract value and trime whitespace and quotes
        string content = line.Substring(start + 1, end - start).Trim(trimChars);

        // Extract URL from url() value
        if (!content.StartsWith("url", StringComparison.InvariantCultureIgnoreCase))
            continue;
        start = content.IndexOf('(');
        end = content.IndexOf(')', start + 1);
        if (start < 0 || end < 0)
            continue;
        start++;
        content = content.Substring(start, end - start).Trim(trimChars);

        if (!content.StartsWith("noimg", StringComparison.InvariantCultureIgnoreCase))
            yield return content;
    }
}

更新:

你所询问的似乎超出了stackoverflow简单操作问题的范围。我认为使用正则表达式无法得到令人满意的结果。您需要一些代码来解析您的CSS,并处理所有与之相关的特殊情况。

既然我已经写了很多解析代码并且有一点时间,所以我决定稍微研究一下。我编写了一个简单的CSS解析器并写了一篇文章介绍它。您可以在A Simple CSS Parser上阅读文章并免费下载代码。

我的代码解析CSS块并将信息存储在数据结构中。我的代码分离并存储每个规则的每个属性/值对。但是,仍需要更多的工作来从属性值中获取URL。您需要从属性值中解析它们。

我最初发布的代码将让您开始了解如何处理此问题。但是,如果您想要一个真正强大的解决方案,则需要更复杂的代码。您可能希望查看我的用于解析CSS的代码。其中使用的技术可以用于轻松处理诸如 url('img(1)') 之类的值,例如解析引号包含的值。

我认为这是一个很好的开始。我也可以为您编写剩余的代码。但那有什么乐趣呢。:)


再次针对我在问题中提供的示例进行优化的版本。我需要处理任何 CSS,而不仅仅是上面提供的那个。你提供的解决方案并没有比正则表达式更好。它也无法解析url('img(1)') - Athari
@Authari:我已经编写了很多解析代码,可以轻松地扩展它来编写更通用的CSS解析代码,就像我在我的答案中建议的那样。但是,我需要更多关于您想要如何构建它的信息,因为可能会涉及到很多信息。您的问题似乎更关注于如何获取URL值。 - Jonathan Wood
1
你的代码也应该具备注释感知能力,以免解析注释。 - Karl-Johan Sjögren
@Karl-JohanSjögren:请查看我的回答更新。评论问题已经得到彻底解决。 - Jonathan Wood

2
在我看来,您创建了太复杂的正则表达式。有效的正则表达式如下:url\s*[(][\s'""]*(?<Url>img[\w]*)[\s'""]*[)]。我将尝试解释一下我所搜索的内容:
  1. url开头
  2. 然后是它后面的所有空格(\s*
  3. 接下来是一个左括号([(]
  4. 0个或多个字符,如:空格、双引号、单引号([\s'""]*
  5. 接下来是“URL”,即以img开头并以零个或多个字母数字字符结尾的内容((?<Url>img[\w]*)
  6. 再次出现0个或多个字符,如:空格、双引号、单引号([\s'""]*
  7. 最后以右括号结束 [)]
完整的工作代码如下:
        var source =
            "b { background: url(img0) }\n" +
            "b { background: url(\"img1\") }\n" +
            "b { background: url(\'img2\') }\n" +
            "b { background: url( img3 ) }\n" +
            "b { background: url( \"img4\" ) }\n" +
            "b { background: url( \'img5\' ) }\n" +
            "b { background: url (img6) }\n" +
            "b { background: url (\"img7\") }\n" +
            "b { background: url (\'img8\') }\n" +
            "{ background: url(\'noimg0) }\n" +
            "{ background: url(noimg1\') }\n" +
            "/*b { background: url(noimg2) }*/\n" +
            "b { color: url(noimg3) }\n" +
            "b { content: \'url(noimg4)\' }\n" +
            "@media screen and (max-width: 1280px) { b { background: url(img9) } }\n" +
            "b { background: url(img10) }";


        string strRegex = @"url\s*[(][\s'""]*(?<Url>img[\w]*)[\s'""]*[)]";
        var reUrls = new Regex(strRegex);

        var result = reUrls.Matches(source)
                           .Cast<Match>()
                           .Select(match => match.Groups["Url"].Value).ToArray();
        bool isOk = true;
        for (var i = 0; i <= 10; i++)
        {
            if (!result.Contains("img" + i))
            {
                Console.WriteLine("Missing img"+i);
                isOk = false;
            }
        }
        for (var i = 0; i <= 4; i++)
        {
            if (result.Contains("noimg" + i))
            {
                Console.WriteLine("Redundant noimg" + i);
                isOk = false;
            }
        }
        if (isOk)
        {
            Console.WriteLine("Yes. It is ok :). The result is:");
            foreach (var s in result)
            {
                Console.WriteLine(s);
            }

        }
        Console.ReadLine();

2
“img”只是一个例子。这段代码需要解析任何CSS文件。 - Athari
那么 imgnoimg 之间有什么区别?是语法错误吗? - Piotr Stapp
语法错误、注释、无效属性等——浏览器将加载“img”文件,但不会加载“noimg”文件。 - Athari
也许我会问一个愚蠢的问题:你想要实现什么目标?我有一个处理你的问题的想法,但是要实现它并不容易。 - Piotr Stapp
我想下载一个HTML页面所依赖的所有文件。这需要获取HTML页面链接到的CSS文件中使用的所有图像。 - Athari
所以对于您来说,下载更多不必要的文件并不是问题。 您只会有一些冗余文件(noimg *),但一切都会正常工作。 以下正则表达式 url\s*[(][\s]*(?<Url>([^"')]+|["][^"')]+["]|['][^"')]+[']))\s*[)] 将提取每个img * + noimg [2,3,4]。 如果您删除所有注释,则可以优化解决方案:http://stackoverflow.com/questions/5272167/using-regex-to-remove-css-comments - Piotr Stapp

1

1
你需要使用负回顾后断言来判断是否存在没有跟随*//*,像这样:
(?<!\/\*([^*]|\*[^\/])*)

这段文字难以理解,意思是: (?<! -> 这个匹配之前不应该有:

\/\* -> /*(用转义斜杠)后面跟着

([^*] -> 任何字符都不是 *

|\*[^\/]) -> 或者是一个 *,但其本身后面跟着任何不是 / 的东西的字符

*) -> 对于这个 没有 * 或一个 * 没有 / 字符,我们可以有 0 或多个,并最终关闭负向回顾功能

你需要使用正向回顾功能来查看要设置的属性是否是接受 url() 值的 CSS 属性。如果您只关心例如 background:background-image:,则整个正则表达式如下:

(?<!\/\*([^*]|\*[^\/])*)
(?<=background(?:-image)?:\s*)
url\s*\(\s*(('|")?)[^\n'"]+\1\s*\)

由于此版本要求CSS属性background:background-image:在url()之前,因此它将无法检测到'url(noimg4)'。您可以使用简单的管道符号来添加更多接受的CSS属性:(?<=(?:border-image|background(?:-image)?):\s*) 我使用了\1而不是\k<Quote>,因为我不熟悉那种语法,这意味着您需要使用?:来不捕获不需要的子组。就我所测试的而言,这应该是有效的。
最后,我对实际URL使用了[^\n'"],因为根据您的评论,url('img(1)')应该可以工作,而您在OP中使用的[^\)]则无法解析它。

  1. CSS允许在声明内部添加注释,据我所知,因此仅在声明边界检查注释是不正确的。
  2. 如果您想使正则表达式更易读而不必解释每个符号,可以使用(?n)(?x)选项。
  3. 请参阅反向引用结构了解\k语法。
- Athari
啊,是的,我想这个不会接受 background:/* something */ url(img3) 这是有效的。 - asontu

1
这个解决方案可以避免注释,并处理background-image。它还处理可能包含属性如background-colorbackground-positionrepeatbackground,这不是background-image的情况。这就是为什么我添加了这些情况:noimg5img11img12
数据:
string subject =
    @"b { background: url(img0) }
      b { background: url(""img1"") }
      b { background: url('img2') }
      b { background: url( img3 ) }
      b { background: url( ""img4"" ) }
      b { background: url( 'img5' ) }
      b { background: url (img6) }
      b { background: url (""img7"") }
      b { background: url ('img8') }
      { background: url('noimg0) }
      { background: url(noimg1') }
      /*b { background: url(noimg2) }*/
      b { color: url(noimg3) }
      b { content: 'url(noimg4)' }
      @media screen and (max-width: 1280px) { b { background: url(img9) } }
      b { background: url(img10) }
      b { background: #FFCC66 url('img11') no-repeat }
      b { background-image: url('img12'); }
      b { background-image: #FFCC66 url('noimg5') }";

这个模式:

避免使用注释,因为它们首先被匹配。如果一个注释没有关闭(没有 */),那么后面的所有内容都被视为注释 (?>\*/|$)

结果存储在命名捕获组 url 中。

string pattern = @"
        /\*  (?> [^*] | \*(?!/) )*  (?>\*/|$)  # comments
      |
        (?<=
            background
            (?>
                -image \s* :     # optional '-image'
              |
                \s* :
                (?>              # allowed content before url 
                    \s*
                    [^;{}u\s]+   # all that is not a ; { } u
                    \s           # must be followed by one space at least
                )?
            )

            \s* url \s* \( \s*
            ([""']?)             # optional quote (single or double) in group 1
        )
        (?<url> [^""')\s]+ )     # named capture 'url' with an url inside
        (?=\1\s*\))              # must be followed by group 1 content (optional quote)
              ";
RegexOptions options = RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace;
Match m = Regex.Match(subject, pattern, options);
List<string> urls = new List<string>();
while (m.Success)
{
    string url = m.Groups["url"].ToString();
    if (url!="") {
        urls.Add(url);
        Console.WriteLine(url);
    }
    m = m.NextMatch();
}

1
对于这样的问题,简单的方法可能会奏效。
  1. 将所有CSS命令分行(假设CSS已经简化),在这种情况下,我会在“;”或“}”命令处分行。

  2. 读取所有出现在url(*)内的情况,即使是错误的情况。

  3. 创建一个带有命令模式的管道,检测哪些行真正符合条件

    • 3.1 命令1(检测注释)
    • 3.2 命令2(检测语法错误URL)
    • 3.3 ...
  4. 标记OK行后,提取OK Url's

这是一种简单的方法,可以高效地解决问题,而不需要超复杂难以管理的魔术正则表达式。


1

这个 RegEx 似乎可以解决提供的示例:

background: url\s*\(\s*(["'])?\K\w+(?(1)(?=\1)|(?=\s*\)))(?!.*\*/)

1

可能不是最优雅的解决方案,但似乎能完成您需要的工作。

public static List<string> GetValidUrlsFromCSS(string cssStr)
{
    //Enter properties that can validly contain a URL here (in lowercase):
    List<string> validProperties = new List<string>(new string[] { "background", "background-image" });

    List<string> validUrls = new List<string>();
    //We'll use your regex for extracting the valid URLs
    var reUrls = new Regex(@"(?nx)
        url \s* \( \s*
            (
                (?! ['""] )
                (?<Url> [^\)]+ )
                (?<! ['""] )
                |
                (?<Quote> ['""] )
                (?<Url> .+? )
                \k<Quote>
            )
        \s* \)");
    //First, remove all the comments
    cssStr = Regex.Replace(cssStr, "\\/\\*.*?\\*\\/", String.Empty);
    //Next remove all the the property groups with no selector
    string oldStr;
    do
    {
        oldStr = cssStr;
        cssStr = Regex.Replace(cssStr, "(^|{|})(\\s*{[^}]*})", "$1");
    } while (cssStr != oldStr);
    //Get properties
    var matches = Regex.Matches(cssStr, "({|;)([^:{;]+:[^;}]+)(;|})");
    foreach (Match match in matches)
    {
        string matchVal = match.Groups[2].Value;
        string[] matchArr = matchVal.Split(':');
        if (validProperties.Contains(matchArr[0].Trim().ToLower()))
        {
            //Since this is a valid property, extract the URL (if there is one)
            MatchCollection validUrlCollection = reUrls.Matches(matchVal);
            if (validUrlCollection.Count > 0)
            {
                validUrls.Add(validUrlCollection[0].Groups["Url"].Value);
            }
        }
    }
    return validUrls;
}

选择很简单,因为这是唯一一个完整的正则表达式解决方案,没有“作弊”,也就是说,没有假设CSS看起来会像提供的示例一样。它也是最可维护的正则表达式解决方案,因为它不会试图将所有逻辑都塞进一个巨大的“聪明”正则表达式中。 - Athari
有关代码质量的一些注意事项:1)除非你被迫使用旧版本的.NET,否则new List<string>{ new string[] { a, b } }可以重写为new List<string>{ a, b }。2)validProperties可以是一个数组(在函数外声明),因为LINQ包含适用于数组的Contains方法。3)函数可以返回IEnumerable<string>并使用yield return返回项目。4)我还没有检查过,但循环do while似乎是不必要的,因为Regex.Replace应该替换所有出现次数。5)对ToLower的调用应该被替换为带有...的string.Equals - Athari
StringComparison.OrdinalIgnoreCase 参数。 - Athari
1
虽然这可能适用于99.9%的情况,以展示为什么CSS解析器会更好(正如OP所指出的那样),这将失败:content:'/*'; background:url(img1); content:'*/';只是为了未来读者添加。 - asontu

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接