c# 安全地截断 HTML 以获取文章摘要

Posted

技术标签:

【中文标题】c# 安全地截断 HTML 以获取文章摘要【英文标题】:c# Truncate HTML safely for article summary 【发布时间】:2010-12-15 10:41:44 【问题描述】:

有人有这个的 c# 变体吗?

这样我就可以获取一些 html 并显示它,而不会中断作为文章的摘要引导?

Truncate text containing HTML, ignoring tags

免得我重新发明***!

编辑

对不起,新来的,你的权利,应该更好地表达这个问题,这里有更多信息

我希望获取一个 html 字符串并将其截断为一定数量的单词(甚至是字符长度),这样我就可以将它的开头显示为摘要(然后引出主要文章)。我希望保留 html,以便在预览中显示链接等。

我必须解决的主要问题是,如果我们在 1 个或多个标签的中间截断,我们很可能会得到未闭合的 html 标签!

我对解决方案的想法是

    首先将 html 截断为 N 个单词(单词更好,但字符可以)(确保不要停在标签中间并截断 require 属性)

    在这个被截断的字符串中处理打开的 html 标记(也许我会一直把它们粘在堆栈上?)

    然后处理结束标签并确保它们在我弹出它们时与堆栈上的标签匹配?

    如果在此之后有任何打开的标签留在堆栈上,则将它们写入截断字符串的末尾,html 应该很好!!!!

2009 年 12 月 11 日编辑

到目前为止,这是我在 VS2008 中作为单元测试文件拼凑起来的内容,这“可能”对将来的某人有所帮助 我基于 Jan 代码的 hack 尝试在 char 版本 + word 版本的顶部(免责声明:这是肮脏的粗略代码!!就我而言) 我假设在所有情况下都使用“格式良好”的 HTML(但不一定是根据 XML 版本具有根节点的完整文档) Abels XML 版本处于底部,但还没有完全让测试在这个版本上运行(另外需要理解代码)... 我会在有机会改进时更新 发布代码时遇到问题?堆栈上没有上传工具吗?

感谢所有 cmets :)

using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.XPath;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace PINET40TestProject

    [TestClass]
    public class UtilityUnitTest
    
        public static string TruncateHTMLSafeishChar(string text, int charCount)
        
            bool inTag = false;
            int cntr = 0;
            int cntrContent = 0;

            // loop through html, counting only viewable content
            foreach (Char c in text)
            
                if (cntrContent == charCount) break;
                cntr++;
                if (c == '<')
                
                    inTag = true;
                    continue;
                

                if (c == '>')
                
                    inTag = false;
                    continue;
                
                if (!inTag) cntrContent++;
            

            string substr = text.Substring(0, cntr);

            //search for nonclosed tags        
            MatchCollection openedTags = new Regex("<[^/](.|\n)*?>").Matches(substr);
            MatchCollection closedTags = new Regex("<[/](.|\n)*?>").Matches(substr);

            // create stack          
            Stack<string> opentagsStack = new Stack<string>();
            Stack<string> closedtagsStack = new Stack<string>();

            // to be honest, this seemed like a good idea then I got lost along the way 
            // so logic is probably hanging by a thread!! 
            foreach (Match tag in openedTags)
            
                string openedtag = tag.Value.Substring(1, tag.Value.Length - 2);
                // strip any attributes, sure we can use regex for this!
                if (openedtag.IndexOf(" ") >= 0)
                
                    openedtag = openedtag.Substring(0, openedtag.IndexOf(" "));
                

                // ignore brs as self-closed
                if (openedtag.Trim() != "br")
                
                    opentagsStack.Push(openedtag);
                
            

            foreach (Match tag in closedTags)
            
                string closedtag = tag.Value.Substring(2, tag.Value.Length - 3);
                closedtagsStack.Push(closedtag);
            

            if (closedtagsStack.Count < opentagsStack.Count)
            
                while (opentagsStack.Count > 0)
                
                    string tagstr = opentagsStack.Pop();

                    if (closedtagsStack.Count == 0 || tagstr != closedtagsStack.Peek())
                    
                        substr += "</" + tagstr + ">";
                    
                    else
                    
                        closedtagsStack.Pop();
                    
                
            

            return substr;
        

        public static string TruncateHTMLSafeishWord(string text, int wordCount)
        
            bool inTag = false;
            int cntr = 0;
            int cntrWords = 0;
            Char lastc = ' ';

            // loop through html, counting only viewable content
            foreach (Char c in text)
            
                if (cntrWords == wordCount) break;
                cntr++;
                if (c == '<')
                
                    inTag = true;
                    continue;
                

                if (c == '>')
                
                    inTag = false;
                    continue;
                
                if (!inTag)
                
                    // do not count double spaces, and a space not in a tag counts as a word
                    if (c == 32 && lastc != 32)
                        cntrWords++;
                
            

            string substr = text.Substring(0, cntr) + " ...";

            //search for nonclosed tags        
            MatchCollection openedTags = new Regex("<[^/](.|\n)*?>").Matches(substr);
            MatchCollection closedTags = new Regex("<[/](.|\n)*?>").Matches(substr);

            // create stack          
            Stack<string> opentagsStack = new Stack<string>();
            Stack<string> closedtagsStack = new Stack<string>();

            foreach (Match tag in openedTags)
            
                string openedtag = tag.Value.Substring(1, tag.Value.Length - 2);
                // strip any attributes, sure we can use regex for this!
                if (openedtag.IndexOf(" ") >= 0)
                
                    openedtag = openedtag.Substring(0, openedtag.IndexOf(" "));
                

                // ignore brs as self-closed
                if (openedtag.Trim() != "br")
                
                    opentagsStack.Push(openedtag);
                
            

            foreach (Match tag in closedTags)
            
                string closedtag = tag.Value.Substring(2, tag.Value.Length - 3);
                closedtagsStack.Push(closedtag);
            

            if (closedtagsStack.Count < opentagsStack.Count)
            
                while (opentagsStack.Count > 0)
                
                    string tagstr = opentagsStack.Pop();

                    if (closedtagsStack.Count == 0 || tagstr != closedtagsStack.Peek())
                    
                        substr += "</" + tagstr + ">";
                    
                    else
                    
                        closedtagsStack.Pop();
                    
                
            

            return substr;
        

        public static string TruncateHTMLSafeishCharXML(string text, int charCount)
        
            // your data, probably comes from somewhere, or as params to a methodint 
            XmlDocument xml = new XmlDocument();
            xml.LoadXml(text);
            // create a navigator, this is our primary tool
            XPathNavigator navigator = xml.CreateNavigator();
            XPathNavigator breakPoint = null;

            // find the text node we need:
            while (navigator.MoveToFollowing(XPathNodeType.Text))
            
                string lastText = navigator.Value.Substring(0, Math.Min(charCount, navigator.Value.Length));
                charCount -= navigator.Value.Length;
                if (charCount <= 0)
                
                    // truncate the last text. Here goes your "search word boundary" code:        
                    navigator.SetValue(lastText);
                    breakPoint = navigator.Clone();
                    break;
                
            

            // first remove text nodes, because Microsoft unfortunately merges them without asking
            while (navigator.MoveToFollowing(XPathNodeType.Text))
            
                if (navigator.ComparePosition(breakPoint) == XmlNodeOrder.After)
                
                    navigator.DeleteSelf();
                
            

            // moves to parent, then move the rest
            navigator.MoveTo(breakPoint);
            while (navigator.MoveToFollowing(XPathNodeType.Element))
            
                if (navigator.ComparePosition(breakPoint) == XmlNodeOrder.After)
                
                    navigator.DeleteSelf();
                
            

            // moves to parent
            // then remove *all* empty nodes to clean up (not necessary):
            // TODO, add empty elements like <br />, <img /> as exclusion
            navigator.MoveToRoot();
            while (navigator.MoveToFollowing(XPathNodeType.Element))
            
                while (!navigator.HasChildren && (navigator.Value ?? "").Trim() == "")
                
                    navigator.DeleteSelf();
                
            

            // moves to parent
            navigator.MoveToRoot();
            return navigator.InnerXml;
        

        [TestMethod]
        public void TestTruncateHTMLSafeish()
        
            // Case where we just make it to start of HREF (so effectively an empty link)

            // 'simple' nested none attributed tags
            Assert.AreEqual(@"<h1>1234</h1><b><i>56789</i>012</b>",
            TruncateHTMLSafeishChar(
                @"<h1>1234</h1><b><i>56789</i>012345</b>",
                12));

            // In middle of a!
            Assert.AreEqual(@"<h1>1234</h1><a href=""testurl""><b>567</b></a>",
            TruncateHTMLSafeishChar(
                @"<h1>1234</h1><a href=""testurl""><b>5678</b></a><i><strong>some italic nested in string</strong></i>",
                7));

            // more
            Assert.AreEqual(@"<div><b><i><strong>1</strong></i></b></div>",
            TruncateHTMLSafeishChar(
                @"<div><b><i><strong>12</strong></i></b></div>",
                1));

            // br
            Assert.AreEqual(@"<h1>1 3 5</h1><br />6",
            TruncateHTMLSafeishChar(
                @"<h1>1 3 5</h1><br />678<br />",
                6));
        

        [TestMethod]
        public void TestTruncateHTMLSafeishWord()
        
            // zero case
            Assert.AreEqual(@" ...",
                            TruncateHTMLSafeishWord(
                                @"",
                               5));

            // 'simple' nested none attributed tags
            Assert.AreEqual(@"<h1>one two <br /></h1><b><i>three  ...</i></b>",
            TruncateHTMLSafeishWord(
                @"<h1>one two <br /></h1><b><i>three </i>four</b>",
                3), "we have added ' ...' to end of summary");

            // In middle of a!
            Assert.AreEqual(@"<h1>one two three </h1><a href=""testurl""><b class=""mrclass"">four  ...</b></a>",
            TruncateHTMLSafeishWord(
                @"<h1>one two three </h1><a href=""testurl""><b class=""mrclass"">four five </b></a><i><strong>some italic nested in string</strong></i>",
                4));

            // start of h1
            Assert.AreEqual(@"<h1>one two three  ...</h1>",
            TruncateHTMLSafeishWord(
                @"<h1>one two three </h1><a href=""testurl""><b>four five </b></a><i><strong>some italic nested in string</strong></i>",
                3));

            // more than words available
            Assert.AreEqual(@"<h1>one two three </h1><a href=""testurl""><b>four five </b></a><i><strong>some italic nested in string</strong></i> ...",
            TruncateHTMLSafeishWord(
                @"<h1>one two three </h1><a href=""testurl""><b>four five </b></a><i><strong>some italic nested in string</strong></i>",
                99));
        

        [TestMethod]
        public void TestTruncateHTMLSafeishWordXML()
        
            // zero case
            Assert.AreEqual(@" ...",
                            TruncateHTMLSafeishWord(
                                @"",
                               5));

            // 'simple' nested none attributed tags
            string output = TruncateHTMLSafeishCharXML(
                @"<body><h1>one two </h1><b><i>three </i>four</b></body>",
                13);
            Assert.AreEqual(@"<body>\r\n  <h1>one two </h1>\r\n  <b>\r\n    <i>three</i>\r\n  </b>\r\n</body>", output,
             "XML version, no ... yet and addeds '\r\n  + spaces?' to format document");

            // In middle of a!
            Assert.AreEqual(@"<h1>one two three </h1><a href=""testurl""><b class=""mrclass"">four  ...</b></a>",
            TruncateHTMLSafeishCharXML(
                @"<body><h1>one two three </h1><a href=""testurl""><b class=""mrclass"">four five </b></a><i><strong>some italic nested in string</strong></i></body>",
                4));

            // start of h1
            Assert.AreEqual(@"<h1>one two three  ...</h1>",
            TruncateHTMLSafeishCharXML(
                @"<h1>one two three </h1><a href=""testurl""><b>four five </b></a><i><strong>some italic nested in string</strong></i>",
                3));

            // more than words available
            Assert.AreEqual(@"<h1>one two three </h1><a href=""testurl""><b>four five </b></a><i><strong>some italic nested in string</strong></i> ...",
            TruncateHTMLSafeishCharXML(
                @"<h1>one two three </h1><a href=""testurl""><b>four five </b></a><i><strong>some italic nested in string</strong></i>",
                99));
        
    

【问题讨论】:

如果有人编辑了您现在链接到的帖子怎么办? IMO,最好在您自己的帖子中尽可能准确地描述您的问题。 感谢您的更新,我将根据当前方法制定解决方案;-) 谢谢大家,我自己在这里尝试一些肮脏的方式,但不确定它有多少里程,如果我能找到一个体面的可行解决方案,我会发布 如果你厌倦了尝试肮脏的方式,我尝试了一种“干净的方式”算法,它提供了足够的扩展空间。它会正确地切割一个节点,不管它在哪里。使用 XML(与 XHTML 一样)的好处是,您犯的任何错误都会被系统捕获,但有一个很好的例外:早期退化原则。 感谢 Abel,尝试集成您的代码。 【参考方案1】:

编辑:完整解决方案见下文,第一次尝试去除 HTML,第二次不去除

让我们总结一下你想要什么:

结果中没有 HTML 它应该接受&lt;body&gt; 中的任何有效数据 它有一个固定的最大长度

如果您的 HTML 是 XHTML,这将变得微不足道(虽然我还没有看到 php 解决方案,但我非常怀疑他们是否使用了类似的方法,但我相信这是可以理解且相当容易的):

XmlDocument xml = new XmlDocument();

// replace the following line with the content of your full XHTML
xml.LoadXml(@"<body><p>some <i>text</i>here</p><div>that needs stripping</div></body>");

// Get all textnodes under <body> (twice "//" is on purpose)
XmlNodeList nodes = xml.SelectNodes("//body//text()");

// loop through the text nodes, replace this with whatever you like to do with the text
foreach (var node in nodes)

    Debug.WriteLine(((XmlCharacterData)node).Value);

注意:空格等将被保留。这通常是一件好事。

如果你没有 XHTML,你可以使用HTML Agility Pack,它让你对普通的旧 HTML 做同样的事情(它在内部将它转换为一些 DOM)。我没试过,但它应该运行得相当流畅。


大编辑:

实际解决方案

在一个小评论中,我承诺采用 XHTML / XmlDocument 方法并将其用于基于文本长度拆分 HTML 的类型安全方法,但保留 HTML 代码。我采用了以下 HTML,代码在 needs 中间正确地破坏了它,删除了其余部分,删除了空节点并自动关闭了所有打开的元素。

示例 HTML:

<body>
    <p><tt>some<u><i>text</i>here</u></tt></p>
    <div>that <b><i>needs <span>str</span>ip</i></b><s>ping</s></div>
</body>

经过测试的代码可以使用任何类型的输入(好吧,当然,我刚刚进行了一些测试,代码可能包含错误,如果您发现它们,请告诉我!)。

// your data, probably comes from somewhere, or as params to a method
int lengthAvailable = 20;
XmlDocument xml = new XmlDocument();
xml.LoadXml(@"place-html-code-here-left-out-for-brevity");

// create a navigator, this is our primary tool
XPathNavigator navigator = xml.CreateNavigator();
XPathNavigator breakPoint = null;


string lastText = "";

// find the text node we need:
while (navigator.MoveToFollowing(XPathNodeType.Text))

    lastText = navigator.Value.Substring(0, Math.Min(lengthAvailable, navigator.Value.Length));
    lengthAvailable -= navigator.Value.Length;

    if (lengthAvailable <= 0)
    
        // truncate the last text. Here goes your "search word boundary" code:
        navigator.SetValue(lastText);
        breakPoint = navigator.Clone();
        break;
    


// first remove text nodes, because Microsoft unfortunately merges them without asking
while (navigator.MoveToFollowing(XPathNodeType.Text))
    if (navigator.ComparePosition(breakPoint) == XmlNodeOrder.After)
        navigator.DeleteSelf();   // moves to parent

// then move the rest
navigator.MoveTo(breakPoint);
while (navigator.MoveToFollowing(XPathNodeType.Element))
    if (navigator.ComparePosition(breakPoint) == XmlNodeOrder.After)
        navigator.DeleteSelf();   // moves to parent

// then remove *all* empty nodes to clean up (not necessary): 
// TODO, add empty elements like <br />, <img /> as exclusion
navigator.MoveToRoot();
while (navigator.MoveToFollowing(XPathNodeType.Element))
    while (!navigator.HasChildren && (navigator.Value ?? "").Trim() == "")
        navigator.DeleteSelf();  // moves to parent

navigator.MoveToRoot();
Debug.WriteLine(navigator.InnerXml);

代码的工作原理

代码按顺序执行以下操作:

    它遍历所有文本节点,直到文本大小超出允许的限制,在这种情况下它会截断该节点。这会自动将&amp;gt; 等作为一个字符正确处理。 然后它会缩短“中断节点”的文本并重置它。此时它会克隆XPathNavigator,因为我们需要记住这个“断点”。 要解决 MS 错误(实际上是一个古老的错误),我们必须首先删除所有剩余的文本节点,跟随断点,否则我们会冒文本节点自动合并的风险最终成为彼此的兄弟姐妹。注意:DeleteSelf 很方便,但会将导航器位置移动到其父级,这就是为什么我们需要根据上一步中记住的“断点”位置检查当前位置。 然后我们首先做我们想做的事情:删除所有节点断点之后。 不是必需的步骤:清理代码并删除所有空元素。此操作仅用于清理 HTML 和/或过滤特定(不)允许的元素。可以省略。 返回“root”并使用InnerXml 以字符串形式获取内容。

就是这样,相当简单,虽然乍一看可能有点吓人。

PS:如果您使用 XSLT,同样会更容易阅读和理解,它是此类工作的理想工具。

更新:添加了扩展代码示例,基于已编辑的问题更新:添加了一些解释

【讨论】:

HTML Agility Pack 和 SgmlReader 都很好地处理了“HTML 到 XHTML”的需求。我个人更喜欢 SgmlReader,但两者都很好。 这不是原始问题中所问的。应保留格式;但不应计入请求的字符数。 @Jan:问题在哪里这么说?但我很乐意更新相同的方法,包括格式化/计数问题 引用问题中的“我想要的是:”部分。所以 26 字符摘要,应该是 26 字符;加上 HTML 等。请参阅 stian.net 的答案。 谢谢 Jan,到目前为止我还没有阅读。同时编辑了问题的完整描述,我的答案也被编辑了(见下半部分)【参考方案2】:

如果你想维护 html 标签,你可以使用我最近发布的这个 gist。 https://gist.github.com/2413598

它使用 XmlReader/XmlWriter。它还没有准备好生产,即你可能想要 SgmlReader 或 HtmlAgilityPack 并且你想要尝试捕获并选择一些后备......

【讨论】:

【参考方案3】:

好的。这应该有效(脏代码警报):

        string blah = "hoi <strong>dit <em>is test bla meer tekst</em></strong>";
        int aantalChars = 10;


        bool inTag = false;
        int cntr = 0;
        int cntrContent = 0;
        foreach (Char c in blah)
        
            if (cntrContent == aantalChars) break;



            cntr++;
            if (c == '<')
            
                inTag = true;
                continue;
            
            else if (c == '>')
            
                inTag = false;
                continue;
            

            if (!inTag) cntrContent++;
        

        string substr = blah.Substring(0, cntr);

        //search for nonclosed tags
        MatchCollection openedTags = new Regex("<[^/](.|\n)*?>").Matches(substr);
        MatchCollection closedTags = new Regex("<[/](.|\n)*?>").Matches(substr);

        for (int i =openedTags.Count - closedTags.Count; i >= 1; i--)
        
            string closingTag = "</" + openedTags[closedTags.Count + i - 1].Value.Substring(1);
            substr += closingTag;
        

【讨论】:

谢谢 Jan,目前正在测试这个 看起来有点“荷兰语”:aantalChars >>> amountChars ;)。看起来是一个很好的开始,但是...当您的代码在 we 中间切入时如何使用 How &lt;b&gt;many &lt;!-- help! --&gt;&lt;i&gt;do&lt;u&gt;we&lt;span&gt;need&lt;/span&gt;actually&lt;/u&gt;?&lt;/i&gt;.&lt;/b&gt; 运行? 我不知道?试试吧?我认为How &lt;b&gt;many &lt;!-- help! --&gt;&lt;i&gt;do&lt;u&gt;w&lt;/u&gt;&lt;/i&gt;&lt;/b? 我认为这不适用于自关闭标签,例如 尤其是如果它们尚未关闭 【参考方案4】:

这很复杂,据我所知,没有一个 PHP 解决方案是完美的。如果文本是:

substr("Hello, my <strong>name is <em>Sam</em>. I&acute;m a 
  web developer.  And this text is very long and all the text 
  is inside the sam html tag..</strong>",0,26)."..."

实际上,您必须遍历整个文本才能找到起始 strong 标记的结尾。

我对您的建议是删除摘要中的所有 html。 如果您向用户展示自己的 html 代码,请记住使用 html-sanitizing!

祝你好运:)

【讨论】:

剥离 HTML 绝对是最简单的。但是使用 XML + XPath(对于 XHTML,或净化过的 HTML)来完成这项工作使这变得相当微不足道。尽管大部分工作都在正确地“移除其余部分”,但我不会选择复杂或困难的词。但是,用文本解析技术做同样的事情要困难得多(这是 PHP 使用的)。

以上是关于c# 安全地截断 HTML 以获取文章摘要的主要内容,如果未能解决你的问题,请参考以下文章

如何使用 C# 从安全令牌中获取信息

使用 C# 解析 HTML 以获取内容

安全-Pass13之白名单POST型00截断绕过(upload-labs)

安全-Pass12之白名单GET型00截断绕过(upload-labs)

在 C# 中以字符串形式获取类的名称

C# WebClient - 从 URI 获取 HTML 而不是从 OBIEE 获取 CSV