将HTML转换(渲染)为带有正确换行符的文本

42
我需要将 HTML 字符串转换为纯文本(最好使用 HTML Agility 解析器)。同时保留适当的空格和正确的换行。

当我提到 "正确的换行" 时,我的意思是以下代码:


And by "proper line-breaks" I mean that this code:
<div>
    <div>
        <div>
            line1
        </div>
    </div>
</div>
<div>line2</div>

应该被转换为

line1
line2

仅有一个 换行符。

我看到的大部分解决方案只是将所有的 <div> <br> <p> 标签转换为 \n,这显然很糟糕。

有没有关于在 C# 中进行 HTML 到纯文本渲染的 逻辑 的建议?不需要完整的代码,至少常见的逻辑答案如“用换行符替换所有的闭合 DIV,但仅当下一个兄弟节点不是 DIV 时”会非常有帮助。

我尝试过的方法:仅仅使用 .InnerText 属性(显然是错误的),正则表达式(速度慢、痛苦、需要很多技巧,而且正则表达式的速度是 HtmlAgilityPack 的 12 倍 - 我测过了),以及这个 解决方案 和类似的方案(返回的换行符比所需的要多)。


2
可以通过检查HtmlNode类型(块或非块)并进行一些智能布局来实现,如果要采用HtmlAgilityPack路线。除此之外,此BCL类也可以起作用:https://msdn.microsoft.com/zh-cn/library/windows/apps/windows.data.html.htmlutilities.converttotext.aspx - jessehouwing
@jessehouwing 是的,这就是我想的。 附注:我可能应该提到它是一个ASP.NET MVC应用程序(.NET 4),不想使用“metro应用程序”的类。 - Alex from Jitbit
1
您的问题在stackoverflow上被认为是不相关的:“有没有建议一个轻量级的html-to-plaintext渲染引擎用于C#?” - SO不是用于软件推荐的平台。已经有人投票关闭了这个问题,如果不是因为开放的赏金,可能会有更多的投票。您应该考虑重新措辞您的问题。 - antiduh
@antiduh 谢谢,正在做那个。 - Alex from Jitbit
1
依我之见,HTML 本身无法准确地转换为纯文本。至少,在不考虑 CSS 的情况下是不可能完成的。 - Zohar Peled
10个回答

27

以下代码在提供的示例中正常工作,甚至能够处理一些奇怪的东西,比如<div><br></div>,仍有一些需要改进的地方,但基本想法已经存在。请参阅注释。

public static string FormatLineBreaks(string html)
{
    //first - remove all the existing '\n' from HTML
    //they mean nothing in HTML, but break our logic
    html = html.Replace("\r", "").Replace("\n", " ");

    //now create an Html Agile Doc object
    HtmlDocument doc = new HtmlDocument();
    doc.LoadHtml(html);

    //remove comments, head, style and script tags
    foreach (HtmlNode node in doc.DocumentNode.SafeSelectNodes("//comment() | //script | //style | //head"))
    {
        node.ParentNode.RemoveChild(node);
    }

    //now remove all "meaningless" inline elements like "span"
    foreach (HtmlNode node in doc.DocumentNode.SafeSelectNodes("//span | //label")) //add "b", "i" if required
    {
        node.ParentNode.ReplaceChild(HtmlNode.CreateNode(node.InnerHtml), node);
    }

    //block-elements - convert to line-breaks
    foreach (HtmlNode node in doc.DocumentNode.SafeSelectNodes("//p | //div")) //you could add more tags here
    {
        //we add a "\n" ONLY if the node contains some plain text as "direct" child
        //meaning - text is not nested inside children, but only one-level deep

        //use XPath to find direct "text" in element
        var txtNode = node.SelectSingleNode("text()");

        //no "direct" text - NOT ADDDING the \n !!!!
        if (txtNode == null || txtNode.InnerHtml.Trim() == "") continue;

        //"surround" the node with line breaks
        node.ParentNode.InsertBefore(doc.CreateTextNode("\r\n"), node);
        node.ParentNode.InsertAfter(doc.CreateTextNode("\r\n"), node);
    }

    //todo: might need to replace multiple "\n\n" into one here, I'm still testing...

    //now BR tags - simply replace with "\n" and forget
    foreach (HtmlNode node in doc.DocumentNode.SafeSelectNodes("//br"))
        node.ParentNode.ReplaceChild(doc.CreateTextNode("\r\n"), node);

    //finally - return the text which will have our inserted line-breaks in it
    return doc.DocumentNode.InnerText.Trim();

    //todo - you should probably add "&code;" processing, to decode all the &nbsp; and such
}    

//here's the extension method I use
private static HtmlNodeCollection SafeSelectNodes(this HtmlNode node, string selector)
{
    return (node.SelectNodes(selector) ?? new HtmlNodeCollection(node));
}

什么是SafeSelectNodes?你正在使用哪个库? - Bas
当我使用问题中的示例时,在第一个SelectNodes中会抛出NullReferenceException - Bas
抱歉,我忘记包含我总是与HtmlAgilityPack一起使用的扩展方法(顺便说一下,我在SO上找到它了) - Serge Shultz
@SergeShultz 我只是想知道你是否找到了更好的处理多个"\n\n"的方法? - Shyamal Parikh
关于//todo在返回之后,System.Web.HttpUtility会自行处理,只需在最后使用return HttpUtility.HtmlDecode(doc.DocumentNode.InnerText.Trim());即可。 - Ziad Akiki
这种方法在我尝试的第一个测试用例中抛出了“无法创建多个节点元素”的错误,例如<span><span>S1</span><span>S2</span><span> -- 我将另一种替代方案作为单独的答案发布。 - pettys

15

问题:

  1. 非可见标签(script、style)
  2. 块级标签
  3. 行内标签
  4. 换行符
  5. 可换行空格(前导、尾随和多个空格)
  6. 硬空格
  7. 实体

代数决策:

  plain-text = Process(Plain(html))

  Plain(node-s) => Plain(node-0), Plain(node-1), ..., Plain(node-N)
  Plain(BR) => BR
  Plain(not-visible-element(child-s)) => nil
  Plain(block-element(child-s)) => BS, Plain(child-s), BE
  Plain(inline-element(child-s)) => Plain(child-s)   
  Plain(text) => ch-0, ch-1, .., ch-N

  Process(symbol-s) => Process(start-line, symbol-s)

  Process(start-line, BR, symbol-s) => Print('\n'), Process(start-line, symbol-s)
  Process(start-line, BS, symbol-s) => Process(start-line, symbol-s)
  Process(start-line, BE, symbol-s) => Process(start-line, symbol-s)
  Process(start-line, hard-space, symbol-s) => Print(' '), Process(not-ws, symbol-s)
  Process(start-line, space, symbol-s) => Process(start-line, symbol-s)
  Process(start-line, common-symbol, symbol-s) => Print(common-symbol), 
                                                  Process(not-ws, symbol-s)

  Process(not-ws, BR|BS|BE, symbol-s) => Print('\n'), Process(start-line, symbol-s)
  Process(not-ws, hard-space, symbol-s) => Print(' '), Process(not-ws, symbol-s)
  Process(not-ws, space, symbol-s) => Process(ws, symbol-s)
  Process(not-ws, common-symbol, symbol-s) => Process(ws, symbol-s)

  Process(ws, BR|BS|BE, symbol-s) => Print('\n'), Process(start-line, symbol-s)
  Process(ws, hard-space, symbol-s) => Print(' '), Print(' '), 
                                       Process(not-ws, symbol-s)
  Process(ws, space, symbol-s) => Process(ws, symbol-s)
  Process(ws, common-symbol, symbol-s) => Print(' '), Print(common-symbol),
                                          Process(not-ws, symbol-s)

在 HtmlAgilityPack 和 System.Xml.Linq 之间做 C# 决策:

  //HtmlAgilityPack part
  public static string ToPlainText(this HtmlAgilityPack.HtmlDocument doc)
  {
    var builder = new System.Text.StringBuilder();
    var state = ToPlainTextState.StartLine;

    Plain(builder, ref state, new[]{doc.DocumentNode});
    return builder.ToString();
  }
  static void Plain(StringBuilder builder, ref ToPlainTextState state, IEnumerable<HtmlAgilityPack.HtmlNode> nodes)
  {
    foreach (var node in nodes)
    {
      if (node is HtmlAgilityPack.HtmlTextNode)
      {
        var text = (HtmlAgilityPack.HtmlTextNode)node;
        Process(builder, ref state, HtmlAgilityPack.HtmlEntity.DeEntitize(text.Text).ToCharArray());
      }
      else
      {
        var tag = node.Name.ToLower();

        if (tag == "br")
        {
          builder.AppendLine();
          state = ToPlainTextState.StartLine;
        }
        else if (NonVisibleTags.Contains(tag))
        {
        }
        else if (InlineTags.Contains(tag))
        {
          Plain(builder, ref state, node.ChildNodes);
        }
        else
        {
          if (state != ToPlainTextState.StartLine)
          {
            builder.AppendLine();
            state = ToPlainTextState.StartLine;
          }
          Plain(builder, ref state, node.ChildNodes);
          if (state != ToPlainTextState.StartLine)
          {
            builder.AppendLine();
            state = ToPlainTextState.StartLine;
          }
        }

      }

    }
  }

  //System.Xml.Linq part
  public static string ToPlainText(this IEnumerable<XNode> nodes)
  {
    var builder = new System.Text.StringBuilder();
    var state = ToPlainTextState.StartLine;

    Plain(builder, ref state, nodes);
    return builder.ToString();
  }
  static void Plain(StringBuilder builder, ref ToPlainTextState state, IEnumerable<XNode> nodes)
  {
    foreach (var node in nodes)
    {
      if (node is XElement)
      {
        var element = (XElement)node;
        var tag = element.Name.LocalName.ToLower();

        if (tag == "br")
        {
          builder.AppendLine();
          state = ToPlainTextState.StartLine;
        }
        else if (NonVisibleTags.Contains(tag))
        {
        }
        else if (InlineTags.Contains(tag))
        {
          Plain(builder, ref state, element.Nodes());
        }
        else
        {
          if (state != ToPlainTextState.StartLine)
          {
            builder.AppendLine();
            state = ToPlainTextState.StartLine;
          }
          Plain(builder, ref state, element.Nodes());
          if (state != ToPlainTextState.StartLine)
          {
            builder.AppendLine();
            state = ToPlainTextState.StartLine;
          }
        }

      }
      else if (node is XText)
      {
        var text = (XText)node;
        Process(builder, ref state, text.Value.ToCharArray());
      }
    }
  }
  //common part
  public static void Process(System.Text.StringBuilder builder, ref ToPlainTextState state, params char[] chars)
  {
    foreach (var ch in chars)
    {
      if (char.IsWhiteSpace(ch))
      {
        if (IsHardSpace(ch))
        {
          if (state == ToPlainTextState.WhiteSpace)
            builder.Append(' ');
          builder.Append(' ');
          state = ToPlainTextState.NotWhiteSpace;
        }
        else
        {
          if (state == ToPlainTextState.NotWhiteSpace)
            state = ToPlainTextState.WhiteSpace;
        }
      }
      else
      {
        if (state == ToPlainTextState.WhiteSpace)
          builder.Append(' ');
        builder.Append(ch);
        state = ToPlainTextState.NotWhiteSpace;
      }
    }
  }
  static bool IsHardSpace(char ch)
  {
    return ch == 0xA0 || ch ==  0x2007 || ch == 0x202F;
  }

  private static readonly HashSet<string> InlineTags = new HashSet<string>
  {
      //from https://developer.mozilla.org/en-US/docs/Web/HTML/Inline_elemente
      "b", "big", "i", "small", "tt", "abbr", "acronym", 
      "cite", "code", "dfn", "em", "kbd", "strong", "samp", 
      "var", "a", "bdo", "br", "img", "map", "object", "q", 
      "script", "span", "sub", "sup", "button", "input", "label", 
      "select", "textarea"
  };

  private static readonly HashSet<string> NonVisibleTags = new HashSet<string>
  {
      "script", "style"
  };

  public enum ToPlainTextState
  {
    StartLine = 0,
    NotWhiteSpace,
    WhiteSpace,
  }

}

例子:

// <div>  1 </div>  2 <div> 3  </div>
1
2
3
//  <div>1  <br/><br/>&#160; <b> 2 </b> <div>   </div><div> </div>  &#160;3</div>
1

  2
 3
//  <span>1<style> text </style><i>2</i></span>3
123
//<div>
//    <div>
//        <div>
//            line1
//        </div>
//    </div>
//</div>
//<div>line2</div>
line1
line2

2
过了一段时间,我实际上发现这种方法比被接受的答案更好。 - Alex from Jitbit

2

2021年3月更新最佳答案

此次更新包括HtmlAgilityPack更改(使用新方法替代不存在的方法)以及HTML解码HTML实体(例如 )。

public static string FormatLineBreaks(string html)
{
    //first - remove all the existing '\n' from HTML
    //they mean nothing in HTML, but break our logic
    html = html.Replace("\r", "").Replace("\n", " ");

    //now create an Html Agile Doc object
    HtmlDocument doc = new HtmlDocument();
    doc.LoadHtml(html);

    //remove comments, head, style and script tags
    foreach (HtmlNode node in doc.DocumentNode.SelectNodes("//comment() | //script | //style | //head") ?? Enumerable.Empty<HtmlNode>())
    {
        node.ParentNode.RemoveChild(node);
    }

    //now remove all "meaningless" inline elements like "span"
    foreach (HtmlNode node in doc.DocumentNode.SelectNodes("//span | //label") ?? Enumerable.Empty<HtmlNode>()) //add "b", "i" if required
    {
        node.ParentNode.ReplaceChild(HtmlNode.CreateNode(node.InnerHtml), node);
    }

    //block-elements - convert to line-breaks
    foreach (HtmlNode node in doc.DocumentNode.SelectNodes("//p | //div") ?? Enumerable.Empty<HtmlNode>()) //you could add more tags here
    {
        //we add a "\n" ONLY if the node contains some plain text as "direct" child
        //meaning - text is not nested inside children, but only one-level deep

        //use XPath to find direct "text" in element
        var txtNode = node.SelectSingleNode("text()");

        //no "direct" text - NOT ADDDING the \n !!!!
        if (txtNode == null || txtNode.InnerHtml.Trim() == "") continue;

        //"surround" the node with line breaks
        node.ParentNode.InsertBefore(doc.CreateTextNode("\r\n"), node);
        node.ParentNode.InsertAfter(doc.CreateTextNode("\r\n"), node);
    }

    //todo: might need to replace multiple "\n\n" into one here, I'm still testing...

    //now BR tags - simply replace with "\n" and forget
    foreach (HtmlNode node in doc.DocumentNode.SelectNodes("//br") ?? Enumerable.Empty<HtmlNode>())
        node.ParentNode.ReplaceChild(doc.CreateTextNode("\r\n"), node);

    //finally - return the text which will have our inserted line-breaks in it
    return WebUtility.HtmlDecode(doc.DocumentNode.InnerText.Trim());

    //todo - you should probably add "&code;" processing, to decode all the &nbsp; and such
}

1
下面的类提供了一个替代实现来获取innerText。它不会为后续的
标签发出多个换行符,因为它只考虑区分不同文本内容的标签。每个文本节点的父节点都会被评估以决定是否插入换行符或空格。因此,任何不包含直接文本的标签都会自动被忽略。
你提供的案例得到了你期望的相同结果。此外:
<div>ABC<br>DEF<span>GHI</span></div>

提供

ABC
DEF GHI

<div>ABC<br>DEF<div>GHI</div></div>

提供

ABC
DEF
GHI

由于 div 是块级标签,所以会完全忽略 scriptstyle 元素。使用 System.Web 中的 HttpUtility.HtmlDecode 实用程序方法来解码类似于 &amp; 的 HTML 转义文本。多个空格 (\s+) 会被替换为单个空格。如果重复出现,br 标记不会导致多个换行符。

static class HtmlTextProvider
{
    private static readonly HashSet<string> InlineElementNames = new HashSet<string>
    {
        //from https://developer.mozilla.org/en-US/docs/Web/HTML/Inline_elemente
        "b", "big", "i", "small", "tt", "abbr", "acronym", 
        "cite", "code", "dfn", "em", "kbd", "strong", "samp", 
        "var", "a", "bdo", "br", "img", "map", "object", "q", 
        "script", "span", "sub", "sup", "button", "input", "label", 
        "select", "textarea"
    }; 

    private static readonly Regex WhitespaceNormalizer = new Regex(@"(\s+)", RegexOptions.Compiled);

    private static readonly HashSet<string> ExcludedElementNames = new HashSet<string>
    {
        "script", "style"
    }; 

    public static string GetFormattedInnerText(this HtmlDocument document)
    {
        var textBuilder = new StringBuilder();
        var root = document.DocumentNode;
        foreach (var node in root.Descendants())
        {
            if (node is HtmlTextNode && !ExcludedElementNames.Contains(node.ParentNode.Name))
            {
                var text = HttpUtility.HtmlDecode(node.InnerText);
                text = WhitespaceNormalizer.Replace(text, " ").Trim();
                if(string.IsNullOrWhiteSpace(text)) continue;
                var whitespace = InlineElementNames.Contains(node.ParentNode.Name) ? " " : Environment.NewLine;
                //only 
                if (EndsWith(textBuilder, " ") && whitespace == Environment.NewLine)
                {
                    textBuilder.Remove(textBuilder.Length - 1, 1);
                    textBuilder.AppendLine();
                }
                textBuilder.Append(text);
                textBuilder.Append(whitespace);
                if (!char.IsWhiteSpace(textBuilder[textBuilder.Length - 1]))
                {
                    if (InlineElementNames.Contains(node.ParentNode.Name))
                    {
                        textBuilder.Append(' ');
                    }
                    else
                    {
                        textBuilder.AppendLine();
                    }
                }
            }
            else if (node.Name == "br" && EndsWith(textBuilder, Environment.NewLine))
            {
                textBuilder.AppendLine();
            }
        }
        return textBuilder.ToString().TrimEnd(Environment.NewLine.ToCharArray());
    }

    private static bool EndsWith(StringBuilder builder, string value)
    {
        return builder.Length > value.Length && builder.ToString(builder.Length - value.Length, value.Length) == value;
    }
}

<div>line1</div>line2上失败。 - Alex from Jitbit
@jitbit 这取决于您提供的示例的父级。它在<span><div>line1</div>line2</span>上失败了。似乎应该在之前和之后都强制换行,类似于块元素的实际逻辑。我改变了逻辑以适应这一点。 - Bas

1
我不相信SO是为了交换赏金来编写完整代码解决方案的。我认为最好的答案是那些给出指导并帮助你自己解决问题的答案。在这种精神下,以下是我认为应该起作用的过程:
  1. 用单个空格替换任何长度的空白字符(这是为了表示标准HTML空白处理规则)
  2. 将所有</div>实例替换为换行符
  3. 将多个换行符的实例折叠成一个换行符
  4. 使用换行符替换</p><br><br/>的实例
  5. 删除任何剩余的html开/关标签
  6. 根据需要扩展任何实体,例如&trade;
  7. 修剪输出以删除尾随和前导空格
基本上,每个段落或换行符制造一个换行符,但要用单个换行符折叠多个div闭合 - 所以先做这些。
最后请注意,您正在执行HTML布局,这取决于标签的CSS。您看到的行为是因为div默认为块显示/布局模式。CSS可以改变这一点。没有简单的方法来解决这个问题,除非有一个无头布局/渲染引擎,即可以处理CSS的东西。
但对于您的简单示例情况,上述方法应该是可靠的。

在某些情况下,应该将打开的 <div> <p> 替换为换行符。 - Alex from Jitbit
我可以问一下 - 如果您已经了解了上述所有内容和您所指示的规则,那么是什么阻止您自己编写代码呢?看起来似乎没有太多的SO问题了 - 如果您正在寻找一个库或者需要有人为您编写代码,我不确定这是否在这里的权限范围内。 - Kieren Johnstone
我做不到! :) 我花了太长时间去解决这个问题,似乎想不出一个简单的逻辑/规则集来覆盖所有情况... Serge Shutlz和Bas已经接近了,两者都给出了很好的答案。逻辑是“忽略没有直接(未嵌套)文本的html节点”。 - Alex from Jitbit

0
以下代码适用于我:
 static void Main(string[] args)
        {
              StringBuilder sb = new StringBuilder();
        string path = new WebClient().DownloadString("https://www.google.com");
        HtmlDocument htmlDoc = new HtmlDocument();
        ////htmlDoc.LoadHtml(File.ReadAllText(path));
        htmlDoc.LoadHtml(path);
        var bodySegment = htmlDoc.DocumentNode.Descendants("body").FirstOrDefault();
        if (bodySegment != null)
        {
            foreach (var item in bodySegment.ChildNodes)
            {
                if (item.NodeType == HtmlNodeType.Element && string.Compare(item.Name, "script", true) != 0)
                {
                    foreach (var a in item.Descendants())
                    {
                        if (string.Compare(a.Name, "script", true) == 0 || string.Compare(a.Name, "style", true) == 0)
                        {
                            a.InnerHtml = string.Empty;
                        }
                    }
                    sb.AppendLine(item.InnerText.Trim());
                }
            }
        }


            Console.WriteLine(sb.ToString());
            Console.Read();
        }

尝试使用 htmlDoc.LoadHtml(new WebClient().DownloadString("https://www.google.com")) ;) - Bas
编辑了代码,但似乎谷歌页面在每个div中有太多的脚本,结果太混乱了。我试图比较数据并感到困惑,所以想得到一些帮助 :) ... 请告诉我上述代码的运行情况如何。 - Dreamweaver
我有一个疑问:- 我们需要将每个嵌套的div数据放在单独的行上吗? - Dreamweaver
这将为每个元素添加换行符,而不仅仅是块元素。 - Serge Shultz
只是澄清我的理解输出: 1)如果 html 有 <div><div>te</div></div>st</div>test1</div>,那么预期的输出是什么? 2)如果 html 有 <div><span>test</span>test1</div>,那么预期的输出是什么? 3)如果 div 里面有一个表格,那么表格数据应该如何显示?这将有助于修改上面的代码。 - Dreamweaver
显示剩余2条评论

0

我在我的项目中经常使用CsQuery。它据说比HtmlAgilityPack更快,而且使用css选择器比xpath更容易。

var html = @"<div>
    <div>
        <div>
            line1
        </div>
    </div>
</div>
<div>line2</div>";

var lines = CQ.Create(html)
              .Text()
              .Replace("\r\n", "\n") // I like to do this before splitting on line breaks
              .Split('\n')
              .Select(s => s.Trim()) // Trim elements
              .Where(s => !s.IsNullOrWhiteSpace()) // Remove empty lines
              ;

var result = string.Join(Environment.NewLine, lines);

以上代码按预期工作,但如果您有更复杂的示例并且有一个预期结果,这段代码可以轻松地适应。
例如,如果您想保留<br>,则可以在html变量中将其替换为“---br---”,然后在最终结果中再次拆分。

啊嗯...它不起作用。使用<div>line1</div>line2进行测试。另外 - 为什么要删除空行??如果它们是有意的呢?比如<br><br>此外,CsQuery速度较慢。请参见此处的图表:https://github.com/FlorianRappl/AngleSharp/wiki/Performance - Alex from Jitbit

0

我对html-agility-pack不是很了解,但是这里有一个C#替代方案。

    public string GetPlainText()
    {
        WebRequest request = WebRequest.Create("URL for page you want to 'stringify'");
        WebResponse response = request.GetResponse();
        Stream data = response.GetResponseStream();
        string html = String.Empty;
        using (StreamReader sr = new StreamReader(data))
        {
            html = sr.ReadToEnd();
        }

        html = Regex.Replace(html, "<.*?>", "\n");

        html = Regex.Replace(html, @"\\r|\\n|\n|\r", @"$");
        html = Regex.Replace(html, @"\$ +", @"$");
        html = Regex.Replace(html, @"(\$)+", Environment.NewLine);

        return html;
    }

如果您打算在HTML页面中显示此内容,则应将Environment.NewLine替换为<br/>

它将替换所有标签(甚至是内联标签,如<b>)为换行符。此外,如果它们是有意的(如<br><br><br>),多个换行符也可以。 - Alex from Jitbit
5
永远不应该使用正则表达式来解析(x)html。 - Erik Philips
@ErikPhilips 哦,得了吧!你不是认真地说一个只想做一个简单的Replace("<br/>", @"\r\n")的人必须通过HTML AP来完成吧? - Fandango68

0

对我来说,最佳答案不起作用。我下面的贡献认为会更快、更轻,因为它不需要查询文档,而是递归访问每个节点以查找文本节点,并使用三个记录标志处理行内和块元素周围的空格。

using System;
using System.Text;
using HtmlAgilityPack;

public class HtmlToTextConverter {

    public static string Convert(string html) {
        var converter = new HtmlToTextConverter();
        converter.ParseAndVisit(html);
        return converter.ToString();
    }

    private readonly StringBuilder _text = new();
    private bool _atBlockStart = true;
    private bool _atBlockEnd = false;
    private bool _needsInlineWhitespace;

    public override string ToString() => _text.ToString();

    public void ParseAndVisit(string html) {
        var doc = new HtmlDocument();
        doc.LoadHtml(html);
        Visit(doc);
    }

    public void Visit(HtmlDocument doc) => Visit(doc.DocumentNode);

    public void Visit(HtmlNode node) {
        switch (node.NodeType) {
            case HtmlNodeType.Document:
                VisitChildren(node);
                break;

            case HtmlNodeType.Comment:
                break;

            case HtmlNodeType.Text:
                WriteText((node as HtmlTextNode).Text);
                break;

            case HtmlNodeType.Element:
                switch (node.Name) {
                    case "script":
                    case "style":
                    case "head":
                        break;

                    case "br":
                        _text.AppendLine();
                        _atBlockStart = true;
                        _atBlockEnd = false;
                        _needsInlineWhitespace = false;
                        break;

                    case "p":
                    case "div":
                        MarkBlockStart();
                        VisitChildren(node);
                        MarkBlockEnd();
                        break;

                    default:
                        VisitChildren(node);
                        break;
                }
                break;
        }
    }

    private void MarkBlockStart() {
        _atBlockEnd = false;
        _needsInlineWhitespace = false;
        if (!_atBlockStart) {
            _text.AppendLine();
            _atBlockStart = true;
        }
    }

    private void MarkBlockEnd() {
        _atBlockEnd = true;
        _needsInlineWhitespace = false;
        _atBlockStart = false;
    }

    private void WriteText(string text) {
        if (string.IsNullOrWhiteSpace(text)) {
            return;
        }

        if (_atBlockStart || _atBlockEnd) {
            text = text.TrimStart();
        }

        // This would mean this is the first text after a block end,
        // e.g., "...</p>this text"
        if (_atBlockEnd) {
            _text.AppendLine();
        }

        if (_needsInlineWhitespace) {
            _text.Append(" ");
        }

        var trimmedText = text.TrimEnd();
        if (trimmedText != text) {
            // This text has trailing whitespace; if more inline content
            // comes next, we'll need to add a space then; if a block start
            // or block end comes next, we should ignore it.
            _needsInlineWhitespace = true;
        } else {
            _needsInlineWhitespace = false;
        }

        _text.Append(trimmedText);
        _atBlockStart = false;
        _atBlockEnd = false;
    }

    private void VisitChildren(HtmlNode node) {
        if (node.ChildNodes != null) {
            foreach (var child in node.ChildNodes) {
                Visit(child);
            }
        }
    }

}


0

无正则表达式解决方案:

while (text.IndexOf("\n\n") > -1 || text.IndexOf("\n \n") > -1)
{
    text = text.Replace("\n\n", "\n");
    text = text.Replace("\n \n", "\n");
}

正则表达式:

text = Regex.Replace(text, @"^\s*$\n|\r", "", RegexOptions.Multiline).TrimEnd();

另外,就我记得的,

text = HtmlAgilityPack.HtmlEntity.DeEntitize(text);

能否帮个忙。


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