用 Go 如何实现精准统计文章字数

开发 后端
统计字数是一个很常见的需求,很多人印象最深的应该是微博早些时候限制 140 字,而且边输入会边统计剩余字数。现在很多社区文章也会有字数统计的功能,而且可以依据字数来预估阅读时间。比如 Go语言中文网就有这样的功能。

[[377316]]

大家好,我是站长 polarisxu。

今天要聊的内容应该可以当做一道面试题,你可以先想想该怎么实现。

统计字数是一个很常见的需求,很多人印象最深的应该是微博早些时候限制 140 字,而且边输入会边统计剩余字数。现在很多社区文章也会有字数统计的功能,而且可以依据字数来预估阅读时间。比如 Go语言中文网就有这样的功能。

01 需求分析

下手之前先分析下这个需求。从我个人经验看,在实际面试中,针对一个面试题,你的分析过程,循序渐进的解决方案,可以很好的展示你的思考过程。正所谓分析问题、解决问题。这会给你加分的。

我们采用类似词法分析的思路分析这个需求。

一篇文章通常包含如下元素,我们也称之为 token:

  • 普通文字
  • 标点符号
  • 图片
  • 链接(包含各种协议的链接)
  • 代码

其中普通文字通常会分为欧美和中日韩(CJK),因为 CJK 属于表意文字,和欧美字母的文字差异很大。同时这里还涉及到编码的问题。本文假设使用 UTF-8 编码。

对于标点符号,中文标点和英文标点也会很不一样。

此外还有全角和半角的问题。

根据以上分析,对于该需求作如下假定:

  • 空格(包括换行)不算字数;
  • HTML 标签需要剔除;
  • 编码方式:假定为 UTF-8 编码;
  • 标点符号算不算做字数。如果算,像括号这样的按 2 个字算;
  • 链接怎么算?一个链接约定为 1 个字可能更合适,大概阅读时只是把它当链接,而不太会关心链接由什么字母组成;
  • 图片不算做字数,但如果计算阅读时间,可能需要适当考虑图片的影响;
  • 对于技术文章,代码是最麻烦的。统计代码字数感觉是没多大意义的。统计代码行数可能更有意义;

本文的解决方案针对以上的假定进行。

02 Go 语言实现

先看最简单的。

纯英文

根据以上分析,如果文章只包含普通文本且是英文,也就是说,每个字(单词)根据空格分隔,统计是最简单的。

  1. func TotalWords(s string) int { 
  2.  n := 0 
  3.  inWord := false 
  4.  for _, r := range s { 
  5.   wasInWord := inWord 
  6.   inWord = !unicode.IsSpace(r) 
  7.   if inWord && !wasInWord { 
  8.    n++ 
  9.   } 
  10.  } 
  11.  return n 

还有一种更简单的方式:

  1. len(strings.Fields(s)) 

不过看 strings.Fields 的实现,性能会不如第一种方式。

回顾上面的需求分析,会发现这个实现是有 Bug 的。比如下面的例子:

  1. s1 := "Hello,playground" 
  2. s2 := "Hello, playground" 

用上面的实现,s1 的字数是 1,s2 的字数是 2。它们都忽略了标点符号。而且因为写法的多样性(不规范统一),导致计算字数会有误差。所以我们需要对写法进行规范。

规范排版

其实和写代码要有规范一样,文章也是有规范的。比如出版社对于一本书的排版会有明确的规定。为了让我们的文章看起来更舒服,也应该遵循一定的规范。

这里推荐一个 GitHub 上的排版指南:《中文文案排版指北》,它的宗旨,统一中文文案、排版的相关用法,降低团队成员之间的沟通成本,增强网站气质。这个规范开头关于空格的一段话很有意思:

有研究显示,打字的时候不喜欢在中文和英文之间加空格的人,感情路都走得很辛苦,有七成的比例会在 34 岁的时候跟自己不爱的人结婚,而其余三成的人最后只能把遗产留给自己的猫。毕竟爱情跟书写都需要适时地留白。

建议大家可以看看这个指北,一些知名的网站就是按照这个做的。

因为 GCTT 的排版在这个规范做,但人为约束不是最好的方法,所以我开发了一个 Go 工具:https://github.com/studygolang/autocorrect,用于自动给中英文之间加入合理的空格并纠正专用名词大小写。

所以为了让字数统计更准确,我们假定文章是按一定的规范书写的。比如上面的例子,规范的写法是 s2 := "Hello, playground"。不过这里标点不算作字数。

刚去微博上试了一下,发现微博的字数计算方式有点诡异,竟然是 9 个字。

测试一下发现,它直接把两个英文字母算作一个字(两个字节算一个字)。而汉字是正常的。大家可以想想微博是怎么实现的。

中英文混合

中文不像英文,单词之间没有空格分隔,因此开始的那两种方式不适合。

如果是纯中文,我们怎么计算字数呢?

在 Go 语言中,字符串使用 UTF-8 编码,一个字符用 rune 表示。因此在标准库中查找相关计算方法。

  1. func RuneCountInString(s string) (n int

这个方法能计算字符串包含的 rune(字符)数,对于纯中文,就是汉字数。

  1. str := "你好世界" 
  2. fmt.Println(utf8.RuneCountInString(str)) 

以上代码输出 4。

然而,因为很多时候文章会中英文混合,因此我们先采用上面的纯英文的处理方式,即:strings.Fields(),将文章用空格分隔,然后处理每一部分。

  1. func TotalWords(s string) int { 
  2.  wordCount := 0 
  3.    
  4.  plainWords := strings.Fields(s) 
  5.  for _, word := range plainWords { 
  6.   runeCount := utf8.RuneCountInString(word) 
  7.   if len(word) == runeCount { 
  8.    wordCount++ 
  9.   } else { 
  10.    wordCount += runeCount 
  11.   } 
  12.  } 
  13.  
  14.  return wordCount 

增加如下的测试用例:

  1. func TestTotalWords(t *testing.T) { 
  2.  tests := []struct { 
  3.   name  string 
  4.   input string 
  5.   want  int 
  6.  }{ 
  7.   {"en1""hello,playground", 2}, 
  8.   {"en2""hello, playground", 2}, 
  9.   {"cn1""你好世界", 4}, 
  10.   {"encn1""Hello你好世界", 5}, 
  11.   {"encn2""Hello 你好世界", 5}, 
  12.  } 
  13.  for _, tt := range tests { 
  14.   t.Run(tt.name, func(t *testing.T) { 
  15.    if got := wordscount.TotalWords(tt.input); got != tt.want { 
  16.     t.Errorf("TotalWords() = %v, want %v", got, tt.want) 
  17.    } 
  18.   }) 
  19.  } 

发现 en1 和 encn1 测试不通过,因为没有按照上面说的规范书写。因此我们通过程序增加必要的空格。

  1. // AutoSpace 自动给中英文之间加上空格 
  2. func AutoSpace(str string) string { 
  3.  out := "" 
  4.  
  5.  for _, r := range str { 
  6.   out = addSpaceAtBoundary(out, r) 
  7.  } 
  8.  
  9.  return out 
  10.  
  11. func addSpaceAtBoundary(prefix string, nextChar rune) string { 
  12.  if len(prefix) == 0 { 
  13.   return string(nextChar) 
  14.  } 
  15.  
  16.  r, size := utf8.DecodeLastRuneInString(prefix) 
  17.  if isLatin(size) != isLatin(utf8.RuneLen(nextChar)) && 
  18.   isAllowSpace(nextChar) && isAllowSpace(r) { 
  19.   return prefix + " " + string(nextChar) 
  20.  } 
  21.  
  22.  return prefix + string(nextChar) 
  23.  
  24. func isLatin(size int) bool { 
  25.  return size == 1 
  26.  
  27. func isAllowSpace(r rune) bool { 
  28.  return !unicode.IsSpace(r) && !unicode.IsPunct(r) 

这样可以在 TotalWords 函数开头增加 AutoSpace 进行规范化。这时结果就正常了。

处理标点和其他类型

以上例子标点没计算在内,而且如果英文和中文标点混合在一起,情况又复杂了。

为了更好地实现开始的需求分析,重构以上代码,设计如下的结构:

  1. type Counter struct { 
  2.  Total     int // 总字数 = Words + Puncts 
  3.  Words     int // 只包含字符数 
  4.  Puncts    int // 标点数 
  5.  Links     int // 链接数 
  6.  Pics      int // 图片数 
  7.  CodeLines int // 代码行数 

同时将 TotalWords 重构为 Counter 的 Stat 方法,同时记录标点数:

  1. func (wc *Counter) Stat(str string) { 
  2.  wc.Links = len(rxStrict.FindAllString(str, -1)) 
  3.  wc.Pics = len(imgReg.FindAllString(str, -1)) 
  4.  
  5.  // 剔除 HTML 
  6.  str = StripHTML(str) 
  7.  
  8.  str = AutoSpace(str) 
  9.  
  10.  // 普通的链接去除(非 HTML 标签链接) 
  11.  str = rxStrict.ReplaceAllString(str, " "
  12.  plainWords := strings.Fields(str) 
  13.  
  14.  for _, plainWord := range plainWords { 
  15.   words := strings.FieldsFunc(plainWord, func(r rune) bool { 
  16.    if unicode.IsPunct(r) { 
  17.     wc.Puncts++ 
  18.     return true 
  19.    } 
  20.    return false 
  21.   }) 
  22.  
  23.   for _, word := range words { 
  24.    runeCount := utf8.RuneCountInString(word) 
  25.    if len(word) == runeCount { 
  26.     wc.Words++ 
  27.    } else { 
  28.     wc.Words += runeCount 
  29.    } 
  30.   } 
  31.  } 
  32.  
  33.  wc.Total = wc.Words + wc.Puncts 
  34.  
  35. var ( 
  36.  rxStrict = xurls.Strict() 
  37.  imgReg   = regexp.MustCompile(`<img [^>]*>`) 
  38.  stripHTMLReplacer = strings.NewReplacer("\n"" ""</p>""\n""<br>""\n""<br />""\n"
  39.  
  40. // StripHTML accepts a string, strips out all HTML tags and returns it. 
  41. func StripHTML(s string) string { 
  42.  // Shortcut strings with no tags in them 
  43.  if !strings.ContainsAny(s, "<>") { 
  44.   return s 
  45.  } 
  46.  s = stripHTMLReplacer.Replace(s) 
  47.  
  48.  // Walk through the string removing all tags 
  49.  b := GetBuffer() 
  50.  defer PutBuffer(b) 
  51.  var inTag, isSpace, wasSpace bool 
  52.  for _, r := range s { 
  53.   if !inTag { 
  54.    isSpace = false 
  55.   } 
  56.  
  57.   switch { 
  58.   case r == '<'
  59.    inTag = true 
  60.   case r == '>'
  61.    inTag = false 
  62.   case unicode.IsSpace(r): 
  63.    isSpace = true 
  64.    fallthrough 
  65.   default
  66.    if !inTag && (!isSpace || (isSpace && !wasSpace)) { 
  67.     b.WriteRune(r) 
  68.    } 
  69.   } 
  70.  
  71.   wasSpace = isSpace 
  72.  
  73.  } 
  74.  return b.String() 

代码过多的细节不讨论。此外,关于文章内的代码行数统计未实现(目前没有想到特别好的方法,如果你有,欢迎交流)。

03 总结

通过本文的分析发现,精准统计字数没那么容易,这里涉及到很多的细节。

当然,实际应用中,字数不需要那么特别精准,而且对于非正常文字(比如链接、代码)怎么处理,会有不同的约定。

本文涉及到的完整代码放在 GitHub:https://github.com/polaris1119/wordscount。

本文转载自微信公众号「polarisxu」,可以通过以下二维码关注。转载本文请联系polarisxu公众号。

 

责任编辑:武晓燕 来源: polarisxu
相关推荐

2024-11-12 10:09:59

Go语言第三方库

2020-06-07 10:12:14

牛联网畜牧物联网

2012-03-13 10:40:58

Google Go

2021-04-09 20:04:34

区块链Go加密

2020-03-17 10:24:12

Go语言停止写障碍

2018-01-15 09:49:25

Python精准分类

2021-09-15 10:00:33

Go语言Modules

2021-05-29 10:20:54

GoModules语言

2017-11-16 15:25:54

Go语言算法代码

2022-01-04 10:25:32

Go参数加载

2023-12-08 07:55:37

MySQL数据统计InnoDB

2016-12-01 13:22:25

大数据扶贫国际

2021-12-09 10:45:19

分布式事务框架

2009-12-09 16:49:09

PHP显示文章发布时间

2023-02-26 01:37:57

goORM代码

2021-06-09 07:15:20

Go枚举技巧

2022-02-25 10:59:18

AWSGORust

2021-07-12 10:24:36

Go装饰器代码

2023-12-18 19:08:29

数字孪生数字化农业

2022-09-05 14:25:39

农业物联网IOT
点赞
收藏

51CTO技术栈公众号