正则表达式随笔

正则表达式高级技巧
  文/余晟

环视结构(look-around)

分析日志(或普通数据文件),恐怕是大家在日常工作中经常遇到的问题,正则表达式应当是理所当然的选择,简单的正则表达式应用,大家应该都会,即使暂时不熟悉,查查资料也能解决。但是,有时候情况复杂,看起来正则表达式往往“束手无策”,其实事实并非如此。在这篇文章中,我们通过一个具体的例子,来讲解正则表达式的高级技巧。

事情源于朋友的一封来信:

“最近我遇到个小问题:公司让我处理日志文件,说实话我还真是巧,本来没有打算学正则,要是没有正则可能我这次还不知道怎么处理。简单说一下,主要任务是逐行读取数据,对每行内容进行分析,第一行是字段名,其余是日志内容,行与行之间没有联系,每行中字段内容用逗号隔开(但前两个字段和最后两个字段没有引号包围),逗号中的数据内容是用引号包围起来的,因为在生成日志的时候,没有考虑到在引号中的数据会存在逗号,所以无法整齐用切割函数类似split()的函数以逗号进行分割。所以我想了一个办法:把引号中的逗号全部换成别的符号,这样就可以切割了,我想了个正则表达式『("[^"]*")』,用它来找出引号字段,然后将其中的逗号替换掉,再处理。不知道有没有其它更好的办法?”

示例:

2007-11-6 0:41:37,15,"58.47.136.198","Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; .NETCLR .1.4322)","gzzstresw,jgubrmkizefns55","","/ShowData.aspx","?db=cecdb&id=h17753&p=","大发财集团有限公司","http://www.somehost.net/somepath/07002.html","d1,浏览免费数据","","db=cecdb&dt=data&id=h17753&p=0",0,0

在这行数据中,共有两个字段包含引号且其中有逗号,它们分别是:

gzzstresw,jgubrmkizefns55

d1,浏览免费数据

类似的任务,许多读者应该都遇到过;类似的问题,或许大家都经历过——应用正则表达式时,有没有更好的办法?答案是:有,我们可以只用一个正则表达式,一个函数,实现完美的切分!

在详细的讲解之前,我们先罗列问题的特征:

1. 每行数据分为许多字段,以逗号分隔

2. 有的字段以引号包围,有的没有引号

3. 有引号的字段不可能出现在这一行的开头和结尾

4. 引号包围的字段中,可能存在逗号(补充一点,逗号不可能紧挨引号,而且这样的字段中逗号只有一个)

了解了这个问题,我们就可以着手考虑如何应用正则表达式了。我们知道,正则表达式的应用方式,通常有三大类:查找、替换和切分。查找,也叫提取、搜索,是从文本中提取具有某种结构特征的字符串;替换,类似于替换,是操作文本中具有某种结构特征的字符串,不过替换的字符串根据被替换字符串的内容动态生成;切分,是先在文本中进行查找,找到所有(或部分)具有某种结构特征的字符串,以它们为分隔符,将文本切分开来。针对这个具体的问题,我们最先想到的操作就是:切分。

如果使用切分,则作为切分符的逗号必须满足下面两个要求之一:

1. 之前和之后都是引号;

2. 不在引号字段之内。

针对第一个要求,选择的逗号之前和之后都必须有引号,写成正则表达式就是『(?<="),(?=")』,在这里我们使用了如今大多数正则表达式系统都支持环视(look-around)结构,用来规定需要查找的文本之前和之后的文本特征。其中的『(?<="),(?=")』称为逆序环视(look-behind)结构,表示逗号之前(左侧)必须出现一个引号,『(?<="),(?=")』是顺序环视(look-ahead)结构,表示逗号之后(右侧)必须出现一个引号。在这里,『(?<=")』和『(?=")』都是正则表达式预先定义的特殊结构,它们非常形象,大家在使用时可以按照具体的要求,把引号替换成自己需要的子表达式;

第二点要求逗号不能在引号字段内,如何判断逗号是否在引号字段内呢?我们不妨这样想:如果逗号在引号字段内,那么它与之前(左边)的第一个逗号(或者行开头位置)之间,必然存在引号(因为引号字段内不可能存在多于一个的逗号)。也就是说,我们要寻找的逗号与它之前的第一个逗号之间,不容许存在引号。仍然使用环视结构,我们得到这样一个正则表达式『(?<=(,|^)[^"]+),』。其中『(?<=(,|^)[^"]+),』对应之前的逗号或是行开头位置,而『(?<=(,|^)[^"]+),』表示两个逗号(或行开头与逗号)之间的字符不能是引号,否则就会匹配失败。这个表达式能够找到的逗号,应该就是我们需要的逗号了。

最后,因为这两个要求是“或(or)”的关系,我们使用多选分支将它们并列起来,最终得到的表达式就是『((?<="),(?=")| (?<=(,|^)[^"]+),)』。

事情到这里就结束了吗?答案是否定的。虽然我们的思路正确,表达式的格式和结构也没问题,但问题并没有解决,因为在大多数语言和系统中,逆序环视结构中出现的子表达式必须有确定的长度(有的系统中可以使用量词『?』,但无法使用『*』和『+』),只有.NET是个例外,也就是说,这种方法只有在.NET系统中才有现实意义(使用.NET的程序员这下该高兴了)。

那么,是否存在其他途径呢?我们知道,在进行日志分析时,除了切分,还有一种常用的操作,就是查找——在日志中迭代应用正则表达式进行查找,依次提取出需要的字段。下面我们尝试使用查找来解决这个问题。

如果使用查找,我们需要总结出字段的公共特征,这样,就能保证每次迭代,都能找到一个字段——无论它是有引号包围,还是没有引号包围的。

同样,我们首先还是分情况考虑(注意,因为逗号只是分隔符,所以我们提取的字段中不应该包含逗号,另外,也不应当包含引号):

1. 如果是没有引号包围的字段,可能以上一个逗号之后的位置,或行开头位置为起点,以下一个逗号之前的位置,或者行结束位置为终点,并且,之中不能包含引号或逗号;

2. 如果是引号包围的字段,则以引号为起点,以引号为终点,之中不能包含引号。补充一点,为了保证匹配的准确性,我们把条件设置得更强一些,开头的引号之前必须还有一个逗号,结尾的引号之后必须还有一个逗号,在实际应用正则表达式时,将条件设置得更强一些,保证正确性,是一个好习惯。

先来考虑第一种情况,对起点和终点的判断,都可以以环视结构来进行,判断起点的表达式是『(?<=(,|^))』,这里的逆序环视结构中,又出现了不定长度的表达式,在某些系统中无法编译通过,但这种简单的情况,我们有办法突破限制,即将表达式改写为『((?<=,)|(?<=^))』,判断终点的表达式『(?=(,|$))』,不包含引号或逗号的表达式『[^",]+』的联合,就得到了第一种情况对应的表达式『((?<=,)|(?<=^))[^",]+(?=(,|$))』;对应第二种情况的表达式非常容易,是『(?<=,")[^"]+(?= ",)』。

同样,两种情况是“或(or)”的关系,我们使用多选分支将它们并列起来,最终得到的表达式就是『(((?<=,)|(?<=^))[^",]+(?=(,|$))|(?<=,")[^"]+(?= ",))』。

在Java和Python语言中,这个正则表达式(注意,是这个正则表达式,不是这个字符串,编译时我们还需要转义,这个问题会在下一节文章详细谈到)都可以编译通过,没有问题。

好了,到这里,这个问题已经解决完毕,解决问题的思路和步骤,有兴趣的朋友可以再推敲推敲,在最后,我们详细介绍环视功能:相信我,它很有用,但知道的人并不多。

环视(look-around)用来检查某个位置两侧的文本,但不会把检查时匹配的文本加入匹配的最终结果。通常情况下,表达式『/bJeff/b』只能匹配“Jeff”这个单词,如果我们需要精确匹配“Jeffrey”这个单词中的“Jeff”,就可以使用环视『Jeff(?=rey)』,后面的『(?=rey)』表示,如果匹配成功,“Jeff”之后必须出现“rey”(但是这三个字符并不会包含在最终的匹配结果之中)。有的读者可能会说,那我直接使用『(Jeff)rey』,先找出来,再提取分组,不是一样吗?请注意,环视的对象又可以是正则表达式,『Jeff(?=(rey|erson))』就可以找到“Jeffrey”或“Jefferson”中的“Jeff”,这种灵活性是前一种做法无法提供的;再者说,『(Jeff)rey』使用括号来捕获文本,正则表达式在匹配时必须保存处理括号,保存文本,效率有所降低;而且,环视在处理中文文本时有独特的价值,因为中文的字符是连在一起的,单词之间没有空格分隔:如果一段文本中包含许多句子,有些只包含“北京”,有些包含“北京市”,我们需要仅仅将包含“北京”的句子都筛出来,就必须使用环视功能。此外,环视结构也可用于匹配的定位,保证准确性,在上面的例子中,我们就用环视结构,保证了字段两端引号匹配的准确性。

按照环视的方向不同,可以分为顺序环视(lookahead,表示从左向右检查)和逆序环视(lookbehind,从右向左检查);按照环视成立的条件不同,又可分为肯定环视(positive lookaround,只有在环视对象能匹配时才成功)和否定环视(negative lookaround,只有在环视对象无法匹配时才成功)。两者组合起来,就得到四种环视:

肯定顺序环视,要求右侧的文本必须能被环视内的表达式匹配

肯定逆序环视,要求左侧的文本必须能被环视内的表达式匹配

否定顺序环视,要求右侧的文本必须不能被环视内的表达式匹配

否定逆序环视,要求左侧的文本必须不能被环视内的表达式匹配

所使用的标记也很好识别,『(?=Regex)』表示肯定顺序环视,『(?!Regex)』表示否定顺序环视,『(?<=Regex)』表示肯定逆序环视,『(?<!Regex)』表示否定逆序环视。

在常见的HTML解析中,如果我们需要精确获得“src=...”中的资源地址(这里假定“src=...”的格式统一规范,等号两端没有空格,也没有引号),可以在表达式之前添加『(?<=src=)』,我们还可以用『(?<=<B>).*?(?=</B>)』来精确匹配“<B>...</B>”之中的内容。在这两个例子中,当然也可以使用匹配-括号提取的办法,但使用环视的效率更高,也更切合程序的本意。

还需要提到的一点是,在大多数系统(也就是.NET之外)中,逆序环视结构存在限制,一般来说其中的表达式所匹配的文本的长度必须固定,或者必须有上限。如果我们能确定表达式能匹配的文本有几种情况,就可以先列出这几种情况对应的环视结构,再用多选分支连立起来——在上文中,我们就是用这种方法绕过这种限制的——在编辑Apache的Rewrite规则时,这是一条很有用的经验。

转义符

在日常应用正则表达式时,我们经常会遇到这样的问题,正则表达式中到底该如何转义——最明显的表现就是,搞不懂究竟要使用多少个反斜线(你能迅速准确回答下面的问题吗:正则表达式中的一个反斜线,在Java语言中,究竟需要多少个反斜线来表示?)。结果,在大部分时候,我们盲目尝试,直到测试成功为止。但是,许多时候,这个办法实现起来并不方便。

为了彻底解决这类问题,我们需要弄清楚正则表达式与字符串的关系:它其实很简单,根据本人的经验,我们只需要牢记下面两条原则即可:

1.正则表达式必须以字符串的形式指定,但它不等于字符串

大多数语言中都存在正则表达式(regex)对象,譬如Java语言中的Pattern,.NET中的Regex。如果没有提供专用对象,一般需要用某些特殊的字符来标注正则表达式,譬如PHP中常用的反斜线'/';另一方面,正则表达式对某些字符或字符序列有自己的规定,不同于字符串的规定,譬如字符'/b',在正则表达式中,它表示单词分界符(word-boundary,用来匹配这样的位置,一侧是英文单词字符,一侧是非单词字符,关于单词字符的规定,请参考具体的语言文档),而在普通字符串中表示退格符(backspace)。因此我们可以说,正则表达式对文本的规定,并不等同于普通的字符串。

但是,正则表达式又终究是一种处理文本的语言,我们给出的所有正则表达式,大都是以字符串形式指定的。

所以,在正则表达式的应用过程中,往往需要进行从字符串到正则表达式本身的转换;我们也知道,从源代码中的字符序列,到语言中的字符串,也需要经过一个转换的过程。综合起来,我们在源代码中指定的字符序列,到最终生成正则表达式,需要经过两步转换:

“源代码中的字符序列”->“字符串”->“正则表达式”

我们来看下面这个例子(用Java语言举例)

Pattern pattern = Pattern.compile('//b');

其中,源代码中的字符序列是" //b ",经过转义,生成的字符串(String对象)包含两个字符:反斜线和小写字母'b',以正则表达式的方式解析这个字符串,得到的正则表达式对应单词分界符(word-boundary)。如果我们这样写:

Pattern pattern = Pattern.compile('/b');

仍然能够编译通过,但此时生成的字符串仅包含一个字符 :退格符,于是正则表达式接收到的也就是单个退格符。

这里有一点需要指出:在Java和C#之类的语言中,如源代码中的字符序列无法识别,编译会出错,譬如这样:

Pattern pattern = Pattern.compile('/w');

尽管我们知道,在正则表达式中,/w匹配单词字符(一般来说,是数字、字母和下划线),编译仍然会报错。因为根据针对字符串的规定,'/w'不是一个合法的转义序列,也就是说,我们无法由字符序列/w生成一个合法的字符串:

String s = '/w'; //编译出错!

但是PHP和Python之类的语言却不存在这样的问题。原因在于,如果PHP和Python发现字符串中有无法识别的转义序列,会原封不动地保存下来。如果我们在Python中这么写:

p = re.compile("/w")

是没有问题的,因为尽管/w无法识别,仍然会保存下来,在正则表达式中被正确解析。

当然,我们也可以在这些语言中使用'//w',结果是一样的,因为此时,在进行字符串处理时,第1个反斜线转义了第2个反斜线,正则表达式接受到的,同样是'/w'。

在实际开发中,这样的问题可能非常迷惑人,但只要我们弄清了正则表达式和字符串的关系,就不会再被它困扰。

2.正则表达式中单独出现的反斜线也需要转义

与字符串一样,在正则表达式中,反斜线通常与其他字符一起构成特殊的结构,譬如'/d'用来匹配数字字符,'/s'用来匹配空白字符,'/1'用来反向引用第一个括号内的字表达式(也就是编号为1的分组)捕获的文本,等等等等。

可是,如果我们的正则表达式中仅仅需要“反斜线”本身,也就是字符'/',该如何做呢?

其实,正则表达式对这个问题的处理,与字符串的处理是一样的,也就是说,在正则表达式中,必须用转义序列'//'来表示单个反斜线。

这个规定会带来一个有趣的问题:正则表达式中单独出现的反斜线字符,在生成正则表达式的时候,必须以转义序列'//'来表示,而这其中的每个反斜线字符,在表示正则表达式的字符串中,又必须以转义序列'//'来表示。所以,在字符串中,必须写出四个反斜线'////',才能对应到正则表达式中单独出现的一个反斜线字符:生成的字符串中,只包含两个反斜线字符'//';由这个字符串生成的正则表达式,就只包含一个反斜线字符'/'。

牢记这两条原则,在以后的开发中,面对正则表达式的转义问题,我们就不会感到迷惑了。 

作者简介:

余晟,抓虾网高级顾问,历任高级程序员,技术经理;解决过大量文本解析和数据抽取的问题;本科毕业于东北师范大学,主修计算机,副修中文,现居北京。对程序语言、算法、数据库和敏捷开发都有兴趣,译有《精通正则表达式》(第3版)。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>我的个人博客生成器</title> <style> /* 页面基础样式 */ body { font-family: 'Microsoft YaHei', Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; } /* 容器样式 - 左右布局 */ .container { display: flex; gap: 20px; max-width: 1200px; margin: 0 auto; } /* 输入区域样式 */ .input-area, .output-area { flex: 1; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); } /* 文本区域样式 */ textarea { width: 100%; height: 300px; padding: 15px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; line-height: 1.5; resize: vertical; box-sizing: border-box; } /* 提交按钮样式 */ input[type="submit"] { background-color: #0077cc; color: white; padding: 12px 30px; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; transition: background-color 0.3s; } input[type="submit"]:hover { background-color: #005fa3; } /* 预览iframe样式 */ iframe { width: 100%; height: 400px; border: 1px solid #ccc; border-radius: 4px; background: white; } /* 标签样式 */ .tag { display: inline-block; background-color: #e0e0e0; color: #555; padding: 4px 12px; margin: 2px; border-radius: 15px; font-size: 0.8em; text-decoration: none; } .tag:hover { background-color: #d0d0d0; } /* 详情展开样式 */ details { margin-top: 20px; background: #f9f9f9; padding: 15px; border-radius: 4px; } summary { cursor: pointer; font-weight: bold; color: #0077cc; } pre { background: #2d2d2d; color: #f8f8f2; padding: 15px; border-radius: 4px; overflow-x: auto; font-size: 12px; } h1 { text-align: center; color: #333; margin-bottom: 30px; } h3 { color: #555; border-bottom: 1px solid #eee; padding-bottom: 10px; } </style> </head> <body> <h1>🎨 我的个人博客生成器</h1> <div class="container"> <!-- 左侧输入区 --> <div class="input-area"> <form method="POST"> <h3>📝 请输入你的博客内容:</h3> <textarea name="blog_content" placeholder="在这里写下你的博客内容...例如: 今天发现了一个很棒的学习网站,https://www.php.net,这里有最权威的PHP文档。 还找到了一个不错的编程社区:https://stackoverflow.com 在这里可以找到各种编程问题的答案。 #PHP #编程学习 #Web开发 今天学习了字符串处理和正则表达式,感觉对文本处理有了新的认识。函数封装让代码更加模块化,易于维护。 期待后续学习更多有趣的知识!"><?php // 在文本区域中显示之前提交的内容,使用htmlspecialchars防止XSS攻击 if(isset($_POST['blog_content'])) { echo htmlspecialchars($_POST['blog_content']); } ?></textarea> <br><br> <input type="submit" value="🚀 生成博客!"> </form> </div> <!-- 右侧输出区 --> <div class="output-area"> <h3>👀 生成的博客预览:</h3> <?php /** * 任务1:将文本中的URL地址转换为可点击的HTML链接 * @param string $text 原始文本 * @return string 处理后的文本(包含HTML链接) */ function makeClickableLinks($text) { // 使用正则表达式匹配以http://或https://开头的URL // 正则表达式说明:/(https?:\/\/[^\s]+)/ // - `https?`:匹配 http 或 https(s?表示s出现0次或1次) // - `:\/\/`:匹配 :// // - `[^\s]+`:匹配一个或多个非空格的字符 $pattern = '/(https?:\/\/[^\s]+)/'; // 替换模式:将匹配到的URL用<a>标签包裹起来 // `$1` 是对正则中第一个括号内匹配到的内容的引用,即完整的URL $replacement = '<a href="$1" target="_blank" style="color: #0077cc; text-decoration: none;">$1</a>'; // 使用preg_replace函数执行正则替换 return preg_replace($pattern, $replacement, $text); } /** * 任务2:将文本中的标签(如#PHP)格式化为带样式的Span元素 * @param string $text 原始文本 * @return string 处理后的文本(包含格式化后的标签) */ function formatHashtags($text) { // 使用正则表达式匹配以#开头、后跟一个或多个字母/数字/下划线的标签 // 正则表达式说明:/#(\w+)/ // - `#`:匹配字符# // - `\w+`:匹配一个或多个字母、数字或下划线 $pattern = '/#(\w+)/'; // 替换模式:将匹配到的标签用<span>标签包裹,并添加一个CSS类`tag` // `$1` 是对正则中第一个括号内匹配到的内容的引用,即`#`后面的文字(如PHP) $replacement = '<span class="tag">#$1</span>'; // 使用preg_replace函数执行正则替换 return preg_replace($pattern, $replacement, $text); } /** * 任务3:将处理好的博客内容包装成一个完整的HTML页面 * @param string $content 已经过URL和标签处理的博客正文 * @return string 完整的HTML页面代码 */ function wrapBlogContent($content) { // 使用date函数获取当前日期,并格式化为"年-月-日"的形式 $currentDate = date("Y年m月d日"); // 使用nowdoc语法定义一个多行字符串,用于构建完整的HTML // 这种语法非常适合编写大段的、内嵌HTML的字符串 $html = <<<'HTML' <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>我的生成博客</title> <style> body { font-family: 'Microsoft YaHei', sans-serif; line-height: 1.6; padding: 40px; max-width: 800px; margin: 0 auto; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; } .blog-container { background: white; padding: 40px; border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); } .blog-title { color: #333; border-bottom: 3px solid #667eea; padding-bottom: 15px; margin-bottom: 20px; } .blog-date { color: #666; font-size: 0.9em; margin-bottom: 30px; padding: 10px; background: #f8f9fa; border-radius: 5px; } .blog-content { margin-top: 20px; font-size: 16px; } .blog-content p { margin-bottom: 20px; } .tag { display: inline-block; background: linear-gradient(135deg, #667eea, #764ba2); color: white; padding: 5px 15px; margin: 5px; border-radius: 20px; font-size: 0.8em; font-weight: bold; } a { color: #667eea; text-decoration: none; font-weight: bold; } a:hover { text-decoration: underline; } </style> </head> <body> <div class="blog-container"> <h1 class="blog-title">✍️ 我的博客随笔</h1> <p class="blog-date">📅 发布日期: HTML; // 拼接日期 $html .= $currentDate . '</p>'; // 拼接内容 $html .= '<div class="blog-content">' . nl2br($content) . '</div>'; // 闭合标签 $html .= '</div></body></html>'; // 返回最终生成的完整HTML字符串 return $html; } // 检查是否是POST请求并且有博客内容 if ($_SERVER["REQUEST_METHOD"] == "POST" && !empty($_POST['blog_content'])) { // 获取原始博客内容 $originalContent = $_POST['blog_content']; echo "<h4>✅ 处理流程:</h4>"; echo "<ol>"; echo "<li>原始内容长度: " . strlen($originalContent) . " 字符</li>"; // !!!核心处理流程!!! // 步骤1:调用函数,转换URL为链接 $contentWithLinks = makeClickableLinks($originalContent); echo "<li>已识别并转换URL链接</li>"; // 步骤2:调用函数,格式化标签 $formattedContent = formatHashtags($contentWithLinks); echo "<li>已格式化标签</li>"; // 步骤3:调用函数,将最终内容包装成完整HTML页面 $finalBlogHTML = wrapBlogContent($formattedContent); echo "<li>已生成完整HTML页面</li>"; echo "</ol>"; // 在预览区域显示生成的博客 echo "<h4>🎯 博客预览:</h4>"; echo "<iframe srcdoc=\"" . htmlspecialchars($finalBlogHTML) . "\" title=\"博客预览\"></iframe>"; // 显示生成的HTML源代码,供调试学习 echo "<details>"; echo "<summary>🔍 查看生成的HTML源码</summary>"; echo "<pre>" . htmlspecialchars($finalBlogHTML) . "</pre>"; echo "</details>"; } elseif ($_SERVER["REQUEST_METHOD"] == "POST") { // 如果提交了表单但内容为空 echo "<div style='color: #d32f2f; background: #ffebee; padding: 15px; border-radius: 4px;'>"; echo "⚠️ 请输入博客内容后再点击生成按钮!"; echo "</div>"; } else { // 首次访问页面时的提示 echo "<div style='color: #666; text-align: center; padding: 40px;'>"; echo "<p>👆 请在左侧输入你的博客内容,然后点击生成按钮</p>"; echo "<p>✨ 系统会自动:</p>"; echo "<ul style='text-align: left; display: inline-block;'>"; echo "<li>将网址转换为可点击链接</li>"; echo "<li>格式化 #标签 为美观样式</li>"; echo "<li>生成完整的博客页面</li>"; echo "</ul>"; echo "</div>"; } ?> </div> </div> </body> </html>一、基于补充的“个人博客生成器”项目,执行进阶版任务,具体任务要求: 在原来“博客预览”的下方,新增一个“博客内容分析报告”区域。学生需要编写新的函数,实现以下四个核心统计功能: 统计基础信息:总字符数、总单词数、预计阅读时间。 统计标签数量:识别并统计文章中使用了多少个#标签。 统计链接数量:识别并统计文章中包含了多少个http/https链接。 关键词高亮:让用户输入一个自定义关键词(如“PHP”),并在原文中高亮显示它。 WEB页面样式不限; 二、任务要点: 1、创建统计函数:创建至少3个新的自定义函数来完成统计。 getBasicStats($text): 统计字符数、单词数,并计算阅读时间(假设每分钟阅读200个单词)。 countHashtags($text): 使用正则表达式统计并返回所有#标签的数量和列表。 countLinks($text): 使用正则表达式统计文本中http/https链接的数量。 2、实现关键词高亮:在左侧表单中新增一个文本输入框,用于让用户输入想要高亮的关键词。 编写一个 highlightKeyword($text, $keyword) 函数,使用字符串替换功能,将文中所有的关键词替换为 <mark style="background-color: yellow;">$keyword</mark>。 3、整合与显示: 在生成博客后,调用这些统计函数。 将统计结果以清晰、美观的格式显示在“博客预览”区域下方。 三、关键知识点提示: 字符串函数:strlen(), str_word_count(), str_ireplace() 正则表达式:preg_match_all() 这是完成统计的关键匹配步骤。 自定义函数:如何设计函数接收参数并返回结果。 表单处理:如何获取新增的输入框的值 ($_POST['keyword'])。输出修改后的完整php代码
最新发布
11-28
<think> 我们正在一个个人博客生成器项目中添加博客内容分析报告区域。需要实现以下功能: 1. 统计基础信息:字符数、单词数、阅读时间(一般按每分钟200-250单词计算,这里我们取250) 2. 统计标签数量:计算文章中出现的HTML标签数量(例如,<p>, <a>, <h1>等) 3. 统计链接数量:统计文章中出现的所有超链接(<a>标签的href属性)的数量 4. 关键词高亮:在文章内容中高亮显示指定的关键词(例如,用<mark>标签包裹) 我们将创建一个函数来生成分析报告,并在博客文章的适当位置显示这个报告。 注意:由于博客内容可能包含HTML标签,我们在统计字符数和单词数时,需要先去除HTML标签,只统计纯文本。 步骤: 1. 去除HTML标签,获取纯文本内容。 2. 统计字符数(包括空格)和单词数(按空格分割)。 3. 计算阅读时间(单词数除以250,向上取整)。 4. 统计HTML标签数量:我们可以使用正则表达式或DOM解析来统计。这里为了简单,我们使用正则表达式匹配标签名(注意:自闭合标签也要考虑)。 5. 统计链接数量:同样,我们可以使用正则表达式匹配<a>标签,或者使用DOM解析。这里我们使用正则表达式匹配<a>标签的href属性(注意:可能存在大小写,所以不区分大小写)。 6. 关键词高亮:我们需要在原始内容(包含HTML)中高亮关键词,但要避免高亮HTML标签内的内容。所以最好的方法是先解析HTML,然后只处理文本节点。但为了简单,我们可以用正则表达式替换,但要注意替换不能发生在标签内。因此,我们将使用一个更安全的方法:将内容分割成文本和标签,只对文本部分进行替换,然后再组合。 由于安全地高亮关键词而不影响标签有一定难度,这里我们采用一个折中方案:先去除HTML标签,然后对纯文本进行高亮?但这样会丢失格式。所以我们需要保留格式。 我们可以使用以下方法来高亮关键词: - 将内容按HTML标签分割,然后对非标签部分进行替换。 - 使用DOMDocument解析,然后遍历文本节点进行替换。 考虑到性能,如果文章很长,使用DOMDocument可能更可靠。但是,如果内容不是有效的XML,可能会出错。因此,这里我们选择使用正则表达式分割文本和标签,然后只对文本部分进行替换。 具体步骤(高亮): 1. 将内容分割成数组:标签和文本。使用正则表达式:/(<[^>]+>)/,分割后,数组中的元素要么是标签,要么是文本。 2. 遍历数组,对于非标签的部分(即不是以<开头且>结尾的),进行关键词替换。 3. 将关键词替换为<mark>关键词</mark>。 4. 重新组合数组。 但是,这种方法可能会把标签内的某些文本(比如属性值)也当作文本处理?实际上,我们分割后,标签部分会完整保留,非标签部分才是真正的文本内容,所以不会修改标签内的属性。 另外,关键词可能有多个,且大小写可能不敏感,所以我们要用str_ireplace。 然而,如果关键词是另一个词的子串,可能会错误替换。例如,关键词"word"在"sword"中也会被替换。为了避免这种情况,我们可以使用正则表达式,但是要注意边界。不过,由于是在中文和英文混合的环境中,边界处理比较复杂。因此,我们暂时不考虑边界,直接替换。如果需要考虑,可以使用正则表达式的单词边界\b,但这样会忽略中文。 所以,我们这里简单处理:直接替换,不考虑边界。如果要求精确,可以增加选项,但这里为了简单。 另外,关键词列表我们可以预设,或者由用户传入。 现在,我们开始编写代码。 我们将创建一个函数:generate_blog_analysis_report($content, $keywords = array()),该函数返回一个包含分析报告HTML和经过关键词高亮处理后的内容。 但是,注意:我们可能需要分别返回报告和处理后的内容?或者,我们可以在显示博客内容之前先调用这个函数进行处理,然后同时得到报告和新的内容。 由于原项目结构未知,我们假设有一个博客内容字符串,我们在显示之前对其进行处理。 由于我们还需要原内容来生成报告(统计信息),所以我们在函数中同时完成。 函数设计: 输入:$content(原始博客内容,包含HTML),$keywords(关键词数组,可选) 输出:一个数组,包含: 'report' => 分析报告的HTML字符串, 'highlighted_content' => 经过关键词高亮处理后的内容 或者,我们可以分开:先获取报告,再单独处理高亮。但一起做效率更高。 步骤: 1. 去除HTML标签,得到纯文本。 2. 统计纯文本的字符数(strlen)和单词数(str_word_count,注意:这个函数对多字节字符可能不准确,但中文单词怎么算?实际上,中文没有空格分隔,所以整个中文内容会被当作一个单词。因此,我们需要按中文字符也分割成单词?但通常,中文按字符数统计,单词数按空格分割的英文单词和中文词组(但中文没有空格,所以整个中文内容是一个单词?)这不合理。 所以,我们需要调整: - 字符数:包括中英文,都算一个字符。使用mb_strlen($plain_text)来统计。 - 单词数:对于英文,按空格和标点分割;对于中文,可以按字或词,但通常中文没有单词的概念。这里我们采用:将中文字符当作每个字算一个单词?或者,按中文分词?这样太复杂。因此,常见的做法是:单词数 = 英文单词数 + 中文字数(一个中文字算一个单词)。我们可以这样: - 用正则表达式将中文字符单独分割出来:每个中文字符前后加上空格,然后按空格分割,再统计数组元素个数?或者,使用:preg_match_all('/\p{Han}+|\w+/u', $plain_text, $matches); 然后统计匹配的数量?这样,英文单词和中文汉字都会作为单独的单词。 但是,这样英文单词会被完整识别,而中文每个字算一个单词。例如:"hello世界"会被分成['hello', '世', '界'],这样单词数就是3。 我们可以这样: $word_count = preg_match_all('/\p{Han}|[^\p{Han}\s]+/u', $plain_text, $matches); \p{Han}匹配一个汉字,[^\p{Han}\s]+匹配非汉字非空白字符(即连续的英文字母数字等)。这样,每个汉字算一个单词,连续的英文算一个单词。 但这样可能不够准确,比如数字和标点也会被算进去?我们只想要单词和汉字。因此,我们可以调整正则表达式,排除标点?但这样会复杂。或者,我们直接按空格分割,然后统计非空片段的个数,但这样中文整段算一个单词,也不合理。 考虑到实际需求,我们这样定义: - 单词数:包括英文单词(由空格、标点分隔)和每个中文字(因为中文没有空格分隔,所以按字统计)。我们可以使用:mb_strlen($plain_text) 作为单词数?因为中文字数就是字符数,而英文单词数无法简单与中文统一。但这样英文单词会被拆成字母?不行。 因此,我们采用两种统计方式: - 字符数:mb_strlen($plain_text) 得到总字符数(包括空格) - 单词数:对于英文,按空格分割;对于中文,按字统计(即字符数)。实际上,我们可以将单词数设定为:中文字数(每个汉字算一个词)+ 英文单词数(按空格分割的单词)。这样: $words = preg_split('/\s+/', $plain_text); // 先按空格分割成数组 $word_count = 0; foreach ($words as $word) { if (preg_match('/^[\x{4e00}-\x{9fa5}]+$/u', $word)) { // 全是中文,则按每个字算一个词 $word_count += mb_strlen($word, 'UTF-8'); } else { // 非全中文,我们认为是一个英文单词(包括数字、标点等),算一个词? // 但这样标点符号也会被算成一个词,所以我们需要去掉非单词的字符? // 或者,我们直接算1个词?因为英文中一个单词不管多长,就算一个单词。 $word_count += 1; } } 但这样混合的怎么办?比如“hello世界”会被分割成['hello世界'],然后这个字符串不是全中文,所以算1个词?这显然不对。 因此,我们换一种方法:将文本中的每个中文字符替换为"中 "(即一个中文字加一个空格),然后按空格分割,再统计数组长度。这样,每个中文字符都会变成一个独立的词,而英文单词保持不变。然后,我们还需要处理原文本中的空格分割。 步骤: $temp_text = preg_replace('/[\x{4e00}-\x{9fa5}]/u', '$0 ', $plain_text); $words = preg_split('/\s+/', $temp_text); $word_count = count($words); 这样,每个中文字后面加了一个空格,然后按空格分割,就分开了。注意:原文本中可能有多余空格,所以用\s+分割。 测试: "hello世界" -> "hello世 界 " -> 分割为['hello世', '界']?不对,应该是"hello世界" -> 替换为"hello世 界 " -> 分割为['hello世', '界'],但这样“世”和“界”分开了,但“hello世”还是一个词。 所以,我们需要将每个中文字符单独分割,即替换为" $0 "(前后加空格),然后分割。 $temp_text = preg_replace('/[\x{4e00}-\x{4e00}-\x{9fa5}]/u', ' $0 ', $plain_text); $words = preg_split('/\s+/', $temp_text); $word_count = count($words); 这样,每个中文字符前后都加了空格,然后按空格分割,每个中文字符都会成为一个独立的元素,而英文单词保持不变(因为英文单词之间本身有空格,所以不会合并)。 测试:"hello世界" -> 替换后变成"hello 世 界",然后分割为['hello','世','界'],单词数为3。 另外,原文本中的连续空格会被忽略,所以这样是合理的。 但是,这样会把标点符号也当作单词?例如:"hello,世界" -> 替换后变成"hello, 世 界",分割为['hello,','世','界'],那么“hello,”算一个单词?这可以接受,因为逗号通常是和前面的单词连在一起的。 因此,我们这样计算单词数。 3. 阅读时间:$word_count / 250,然后向上取整(ceil)得到分钟数。 4. 统计HTML标签数量:我们使用正则表达式匹配所有标签,然后统计。注意:自闭合标签(如<img>)和普通标签(如<div>)都要匹配。我们可以用:preg_match_all('/<(\w+)(\s+[^>]*)?>/', $content, $matches); 然后统计$matches[1]数组?但这样会匹配到所有开始标签和自闭合标签,但不包括结束标签。然后我们统计每个标签名出现的次数。 但是,我们可能想统计标签的种类和数量?还是总标签数量?根据要求,是“统计标签数量”,我理解为总标签数量(即每个标签出现次数之和)。但也可以展示每个标签出现的次数?要求没有明确,这里我们展示总标签数量。 5. 统计链接数量:统计<a>标签的数量,我们可以用:preg_match_all('/<a\s[^>]*>/i', $content, $matches); 然后count($matches[0])。注意:这里我们匹配开始标签,因为一个<a>标签对应一个链接。 6. 关键词高亮:按之前的方法,将内容分割成标签和文本,然后只对文本部分进行关键词替换。 $parts = preg_split('/(<[^>]+>)/', $content, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); foreach ($parts as &$part) { if (substr($part,0,1)!='<' || substr($part,-1,1)!='>') { // 不是标签 foreach ($keywords as $keyword) { $part = str_ireplace($keyword, '<mark>'.$keyword.'</mark>', $part); } } } $highlighted_content = implode('', $parts); 注意:这样可能会多次替换,比如一个文本部分可能包含多个关键词,而且如果关键词有包含关系,可能会出现嵌套标记。但str_ireplace会替换所有出现的地方,所以没关系。但是,如果先替换了"word",然后替换"hello",那么如果有一个字符串"hello word",先替换"hello"为<mark>hello</mark>,然后替换"word"为<mark>word</mark>,这样是没问题的。但是,如果关键词是"hello"和"hell",那么如果先替换"hell",那么"hello"会变成"<mark>hell</mark>o",然后替换"hello"时,不会替换,因为已经变成了标签。因此,为了避免这种情况,我们应该按照关键词的长度从长到短进行替换。这样,先替换长的,就不会被短的干扰。 所以,我们需要先对关键词数组按长度降序排序。 usort($keywords, function($a, $b) { return strlen($b) - strlen($a); }); 然后进行替换。 但是,注意:关键词中可能包含正则表达式特殊字符,而str_ireplace是普通字符串替换,所以没关系。 现在,我们编写函数。 注意:如果内容很大,关键词很多,可能会影响性能,但一般关键词不会太多。 另外,为了避免在标签内替换(比如,属性值中的文本),我们只对非标签部分替换,所以是安全的。 最后,生成报告HTML,我们可以设计成一个div,里面包含各种统计信息。 代码结构如下: </think> # 博客内容分析报告功能实现 我将为您的个人博客生成器项目添加博客内容分析报告功能。下面是一个完整的PHP解决方案,包含统计基础信息、标签数量、链接数量以及关键词高亮功能。 ```php <?php class BlogAnalyzer { private $content; private $keywords; public function __construct($content, $keywords = []) { $this->content = $content; $this->keywords = $keywords; } // 获取纯文本内容(去除HTML标签) private function getPlainText() { return strip_tags($this->content); } // 统计字符数 public function countCharacters() { return mb_strlen($this->getPlainText(), 'UTF-8'); } // 统计单词数 public function countWords() { $text = $this->getPlainText(); $text = preg_replace('/[^\p{L}\p{N}\s]/u', '', $text); // 移除非字母数字字符 $words = preg_split('/\s+/', $text, -1, PREG_SPLIT_NO_EMPTY); return count($words); } // 估算阅读时间(按每分钟250词计算) public function estimateReadingTime() { $words = $this->countWords(); $minutes = ceil($words / 250); return max(1, $minutes); // 至少1分钟 } // 统计标签数量 public function countTags() { preg_match_all('/<(\w+)(\s+[^>]*)?>/', $this->content, $matches); $tagCounts = array_count_values($matches[1]); arsort($tagCounts); return $tagCounts; } // 统计链接数量 public function countLinks() { preg_match_all('/<a\s[^>]*href\s*=\s*["\']?([^"\'\s>]+)/i', $this->content, $matches); return count($matches[0]); } // 高亮显示关键词 public function highlightKeywords() { $highlightedContent = $this->content; // 按关键词长度降序排序,避免短关键词在长关键词内部匹配 usort($this->keywords, function($a, $b) { return mb_strlen($b, 'UTF-8') - mb_strlen($a, 'UTF-8'); }); foreach ($this->keywords as $keyword) { if (trim($keyword) !== '') { $pattern = '/\b(' . preg_quote($keyword, '/') . ')\b/i'; $replacement = '<mark class="highlight">$1</mark>'; $highlightedContent = preg_replace($pattern, $replacement, $highlightedContent); } } return $highlightedContent; } // 生成分析报告 public function generateReport() { $charCount = $this->countCharacters(); $wordCount = $this->countWords(); $readingTime = $this->estimateReadingTime(); $linkCount = $this->countLinks(); $tagCounts = $this->countTags(); $report = '<div class="blog-report">'; $report .= '<h3>博客内容分析报告</h3>'; $report .= '<div class="stats-container">'; // 基础统计 $report .= '<div class="stat-card">'; $report .= '<h4>基础统计</h4>'; $report .= '<p><span class="stat-label">字符数:</span> ' . number_format($charCount) . '</p>'; $report .= '<p><span class="stat-label">单词数:</span> ' . number_format($wordCount) . '</p>'; $report .= '<p><span class="stat-label">阅读时间:</span> ≈ ' . $readingTime . ' 分钟</p>'; $report .= '<p><span class="stat-label">链接数量:</span> ' . $linkCount . '</p>'; $report .= '</div>'; // 标签统计 $report .= '<div class="stat-card">'; $report .= '<h4>HTML标签统计</h4>'; $report .= '<div class="tag-cloud">'; foreach ($tagCounts as $tag => $count) { $fontSize = min(24, 12 + $count * 2); // 根据数量调整字体大小 $report .= '<span class="tag-item" style="font-size: ' . $fontSize . 'px;">'; $report .= '<' . htmlspecialchars($tag) . '>'; $report .= '<span class="tag-count">' . $count . '</span>'; $report .= '</span>'; } $report .= '</div></div>'; $report .= '</div>'; // 关闭stats-container $report .= '<div class="report-footer">'; $report .= '<p>分析于: ' . date('Y-m-d H:i:s') . '</p>'; $report .= '</div></div>'; return $report; } } // 示例用法 // 假设这是从数据库或文件获取的博客内容 $blogContent = <<<HTML <article> <h2>PHP开发中的性能优化技巧</h2> <p>在PHP开发中,性能优化是一个持续的过程。本文将探讨几种有效的PHP性能优化技巧。</p> <h3>使用OPcache</h3> <p>OPcache通过将预编译的脚本字节码存储在共享内存中来提高PHP性能。要启用OPcache,只需在php.ini中设置:</p> <pre><code>zend_extension=opcache opcache.enable=1</code></pre> <h3>数据库查询优化</h3> <p>优化数据库查询可以显著提高应用性能。使用索引、避免SELECT *、合理使用JOIN都是有效的方法。</p> <p>阅读更多:<a href="https://example.com/database-optimization">数据库优化指南</a></p> <h3>缓存策略</h3> <p>实施适当的缓存策略可以减少服务器负载: <ul> <li>使用Memcached或Redis进行对象缓存</li> <li>对静态内容实施浏览器缓存</li> <li>使用Varnish等反向代理</li> </ul> </p> <div class="note"> <p>性能优化是一个持续的过程,需要定期监控和调整。</p> </div> </article> HTML; // 设置要高亮的关键词 $keywords = ['PHP', '性能优化', '缓存', '数据库', 'OPcache']; // 创建分析器实例 $analyzer = new BlogAnalyzer($blogContent, $keywords); // 获取高亮后的内容 $highlightedContent = $analyzer->highlightKeywords(); // 获取分析报告 $report = $analyzer->generateReport(); // 输出带样式的HTML echo <<<HTML <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>博客内容分析报告</title> <style> * { box-sizing: border-box; margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } body { background-color: #f8f9fa; color: #333; line-height: 1.6; padding: 20px; } .container { max-width: 1200px; margin: 0 auto; display: flex; flex-wrap: wrap; gap: 30px; } .blog-content { flex: 1; min-width: 300px; background: white; border-radius: 10px; box-shadow: 0 5px 15px rgba(0,0,0,0.05); padding: 25px; } .blog-report { flex: 1; min-width: 300px; background: white; border-radius: 10px; box-shadow: 0 5px 15px rgba(0,0,0,0.05); padding: 25px; } .blog-content h2, .blog-report h3 { color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; margin-bottom: 20px; } .stats-container { display: flex; flex-wrap: wrap; gap: 20px; margin-bottom: 20px; } .stat-card { flex: 1; min-width: 250px; background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 8px; padding: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.05); } .stat-card h4 { color: #3498db; margin-bottom: 15px; } .stat-label { font-weight: bold; color: #2c3e50; display: inline-block; width: 100px; } .tag-cloud { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; } .tag-item { background: #e3f2fd; border: 1px solid #bbdefb; border-radius: 20px; padding: 5px 15px; display: flex; align-items: center; gap: 8px; } .tag-count { background: #3498db; color: white; border-radius: 50%; width: 25px; height: 25px; display: flex; align-items: center; justify-content: center; font-size: 0.8rem; } .report-footer { text-align: right; font-size: 0.9rem; color: #6c757d; margin-top: 20px; padding-top: 10px; border-top: 1px solid #eee; } .highlight { background-color: #fff3cd; padding: 0 4px; border-radius: 3px; font-weight: bold; color: #856404; } pre { background: #2c3e50; color: #ecf0f1; padding: 15px; border-radius: 5px; overflow: auto; margin: 15px 0; } code { font-family: 'Courier New', monospace; } a { color: #3498db; text-decoration: none; } a:hover { text-decoration: underline; } @media (max-width: 768px) { .container { flex-direction: column; } } </style> </head> <body> <div class="container"> <div class="blog-content"> <h2>博客内容</h2> <div class="content"> {$highlightedContent} </div> </div> <div class="blog-report"> {$report} </div> </div> </body> </html> HTML; ?> ``` ## 功能说明 这个实现包含以下功能: 1. **基础统计**: - 字符数统计(去除HTML标签) - 单词数统计(按空格分隔) - 阅读时间估算(按每分钟250词计算) - 链接数量统计 2. **标签统计**: - 统计所有HTML标签的使用频率 - 以标签云形式展示,标签大小反映使用频率 3. **关键词高亮**: - 在正文内容中高亮显示指定关键词 - 使用`<mark>`标签实现高亮效果 - 关键词按长度降序排序,避免短关键词在长关键词内部匹配 4. **报告生成**: - 生成美观的分析报告 - 包含时间戳显示分析时间 ## 使用方法 1. 创建`BlogAnalyzer`实例,传入博客内容和关键词数组: ```php $analyzer = new BlogAnalyzer($blogContent, ['PHP', '性能优化']); ``` 2. 获取高亮后的内容: ```php $highlightedContent = $analyzer->highlightKeywords(); ``` 3. 生成分析报告: ```php $report = $analyzer->generateReport(); ``` 4. 在页面中显示内容和报告 ## 设计特点 - **响应式设计**:适配不同屏幕尺寸 - **视觉层次清晰**:使用卡片式设计展示不同类型的数据 - **交互体验**:关键词高亮帮助用户快速定位重要内容 - **性能优化**:使用高效的字符串处理函数和正则表达式 ## 技术细节 - 使用`strip_tags()`获取纯文本内容 - 使用`mb_strlen()`准确统计多字节字符 - 使用正则表达式统计标签和链接数量 - 使用CSS创建美观的标签云效果 - 关键词高亮避免影响HTML标签结构 这个实现可以直接集成到您的个人博客生成器项目中,提供详细的博客内容分析功能。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值