3

背景

当知道要上传的视频资料从20条变成100条时,我就明白,绝对不能再人工处理了。他们总是想当然的认为,录入一条数据需要1分钟,那录入20条数据就是20分钟,录入100条数据,不就是100分钟吗?我有时候,真的很想问问他们,没有考虑过人是会犯错的吗?数据越多,出错的可能就越大;但是数据本身,又是不允许出现纰漏的。那拿什么去保证数据的正确性?刷脸?可能吗?

大多数时候,类似的争论,最终几乎总是会以他们的一句“我不懂技术,你们看着办吧”结束。所以,也懒得去做口舌之争。我尽力尽快做;但是你承不承认事情本身的复杂度,并不会影响事情本身的复杂度。

回到问题本身,究竟如何处理新到来的100条数据以及以后更多的数据,确实是一个必须想办法彻底解决下的问题。

我拿到的原始数据

此处适当象征性的描述下我拿到的数据。以下讨论,单以 10 条数据为例。

一个 word 文档,是一组问题。

内容假定是:

1.【smart-transform】取自 Atom 的 babeljs&coffeescript&typescript 智能转 es5 库
2.【YFMemoryLeakDetector】人人都能理解的 iOS 内存泄露检测工具类
3.【玩转树莓派】使用 sinopia 搭建私有 npm 服务器
4.【小技巧解决大问题】使用 frp 突破阿里云主机无弹性公网 IP 不能用作 Web 服务器的限制
5.【树莓派自动化应用实例】整点提醒自己休息五分钟
6. 借助 frp 随时随地访问自己的树莓派
7.【LuaJIT版】从零开始在 macOS 上配置 Lua 开发环境
8.【最新版】从零开始在 macOS 上配置 Lua 开发环境
9. 关于混合应用开发的未来的一些思考
10.记录我发现的第一个关于 Google 的 Bug

是的,内容中还有各种中文标点。他们有相当一部分人不理解攻城狮为什么喜欢用英文标点,甚至还有人以此为由说我们小学标点符号没学好。懒得解释那么多,但是既然给出来了,作为纯文本,也不用管这么多,照单全收就行了。符号习惯问题本身,也是一个无伤大雅的问题。

另一个 word 文档,是一组问题对应的 Luis 语义分析结果

微软的 Luis 语义分析服务,勉强算是和人工智能沾点边吧,感兴趣的请自行了解下。从客户端角度来说,你给它一个文本字符串,他们分析出来和这个字符串匹配度最高的某个预录入的答案的唯一标记。每个唯一标记 ID,被称作一个 intent。每次请求,最多只有一个匹配度最高的 intent。

感觉已经有的 word 问题,我们的后端小伙伴,送来了另一个 word 文档:

1. smart_transform
2. memory_leakDetector
3. sinopia_npm
4. frp_ip
5. tip_rest
6. frp_anywhere
7. luajit_macos
8. lua_macos
9. app_future
10. google_bug

又是非结构化的数据。显而易见,我们可爱的后端同学,只是简单完成了录入,自己没有做必要的单元测试。这是在等着我去发现问题啊。很久很久以前,我总是幻想着,所有的攻城狮,必然都是各种自动化测试用例,就像树上写的各种敏捷,各种快速迭代。事实上,我见到的许多所谓的敏捷式开发,最终其实只是把成本后置,各种技术债。出来混,真的迟早是要换的。100个问题,逐一去验证,真的是很耗费时间的,而且最终有问题的,数量也不会太多。也就说说,如果手动去做,很有可能寻找问题的时间,要远远大于发现问题的时间。所以,自动化批量测试,是显而易见的。根据不同的场景和需要,快速构建基本够用的批量自动化测试工具链,应该成为每个攻城狮的必修课。

一组勉强算是有规律的分文件夹放置的视频

我依然是象征性的描述下,结构类似于:

/videos/树莓派/【smart-transform】取自 Atom 的 babeljs&coffeescript&typescript 智能转 es5 库.mp4
/videos/树莓派/【YFMemoryLeakDetector】人人都能理解的 iOS 内存泄露检测工具类.mp4
/videos/树莓派/【玩转树莓派】使用 sinopia 搭建私有 npm 服务器.mp4
/videos/树莓派/【小技巧解决大问题】使用 frp 突破阿里云主机无弹性公网 IP 不能用作 Web 服务器的限制.mp4
/videos/frp/【树莓派自动化应用实例】整点提醒自己休息五分钟.mp4
/videos/frp/借助 frp 随时随地访问自己的树莓派.mp4
/videos/Lua/【LuaJIT版】从零开始在 macOS 上配置 Lua 开发环境.mp4
/videos/Lua/【最新版】从零开始在 macOS 上配置 Lua 开发环境.mp4
/videos/Lua/关于混合应用开发的未来的一些思考.mp4
/videos/Lua/记录我发现的第一个关于 Google 的 Bug.mp4

目标数据要求

intent 必须和问题关联起来

显而易见,应该使用 intent 作为数据的唯一 id。为了便于处理,索性写成了一个 JS 模块。之所以不直接用 JSON,是因为模块比 JSON 文件,更灵活性,后期扩展方便,如果有的话。

这一步是必须手动做的,或者说总是需要有一个人手动去做的。为了效率,团队内总是需要有一个人必须要充当这个角色。

大致处理下,初版结构 intent_info.js 大概类似这样:

module.exports = {
  /* 树莓派 */
  "smart_transform":"【smart-transform】取自 Atom 的 babeljs&coffeescript&typescript 智能转 es5 库",
  "memory_leakDetector":"【YFMemoryLeakDetector】人人都能理解的 iOS 内存泄露检测工具类",
  "sinopia_npm":"【玩转树莓派】使用 sinopia 搭建私有 npm 服务器",
  "frp_ip":"【小技巧解决大问题】使用 frp 突破阿里云主机无弹性公网 IP 不能用作 Web 服务器的限制",
  /* frp */
  "tip_rest":"【树莓派自动化应用实例】整点提醒自己休息五分钟",
  "frp_anywhere":"借助 frp 随时随地访问自己的树莓派",
  /* Lua */
  "luajit_macos":"【LuaJIT版】从零开始在 macOS 上配置 Lua 开发环境",
  "lua_macos":"【最新版】从零开始在 macOS 上配置 Lua 开发环境",
  "app_future":"关于混合应用开发的未来的一些思考",
  "google_bug":"记录我发现的第一个关于 Google 的 Bug",
}

排序

排序,是需要增加一个新的字段 order。不过,我就直接上面的类似 JSON 的结构来排序的。因为排序是由另外一个人做,懂技术,操作很简单些。

经过对方排序后,intent_info.js,可能变成了这样:

module.exports = {
  /* 树莓派 */
  "smart_transform":"【smart-transform】取自 Atom 的 babeljs&coffeescript&typescript 智能转 es5 库",
  "memory_leakDetector":"【YFMemoryLeakDetector】人人都能理解的 iOS 内存泄露检测工具类",
  "sinopia_npm":"【玩转树莓派】使用 sinopia 搭建私有 npm 服务器",
  "frp_ip":"【小技巧解决大问题】使用 frp 突破阿里云主机无弹性公网 IP 不能用作 Web 服务器的限制",
  /* Lua */
  "luajit_macos":"【LuaJIT版】从零开始在 macOS 上配置 Lua 开发环境",
  "lua_macos":"【最新版】从零开始在 macOS 上配置 Lua 开发环境",
  "app_future":"关于混合应用开发的未来的一些思考",
  "google_bug":"记录我发现的第一个关于 Google 的 Bug",
  /* frp */
  "tip_rest":"【树莓派自动化应用实例】整点提醒自己休息五分钟",
  "frp_anywhere":"借助 frp 随时随地访问自己的树莓派",
}

在上面的优先显示。在真正生成 order 字段时,是借助 Node 一个不太可靠的特性: 字典遍历时,会基于key的书写顺序来遍历。这一点,在 Node 和 Android 浏览器上都是成立的,在 safari 上,无效。一般开发时,不应依赖于这一点,不过目前,我只是需要一个够用的东西。Node 的这个特性,在短时间内,应该是不会有改变的。

分类

没过几天,果然又加了新需求,说是视频太多了,太杂乱,想给每个视频加个分类,然后可以按分类查看视频。

好,那我给你加个分类:

module.exports = {
  /* 树莓派 */
  "树莓派":"_category",
  "smart_transform":"【smart-transform】取自 Atom 的 babeljs&coffeescript&typescript 智能转 es5 库",
  "memory_leakDetector":"【YFMemoryLeakDetector】人人都能理解的 iOS 内存泄露检测工具类",
  "sinopia_npm":"【玩转树莓派】使用 sinopia 搭建私有 npm 服务器",
  "frp_ip":"【小技巧解决大问题】使用 frp 突破阿里云主机无弹性公网 IP 不能用作 Web 服务器的限制",
  /* Lua */
  "Lua":"_category",
  "luajit_macos":"【LuaJIT版】从零开始在 macOS 上配置 Lua 开发环境",
  "lua_macos":"【最新版】从零开始在 macOS 上配置 Lua 开发环境",
  "app_future":"关于混合应用开发的未来的一些思考",
  "google_bug":"记录我发现的第一个关于 Google 的 Bug",
  /* frp */
  "frp":"_category",
  "tip_rest":"【树莓派自动化应用实例】整点提醒自己休息五分钟",
  "frp_anywhere":"借助 frp 随时随地访问自己的树莓派",
}

新加了几个值为 _category 的字段。当检测到值为 _category 时,就自动判定为是一个分类。我这种处理方式,免不了引来一阵唏嘘。但是,许多时候,你选择的技术策略,都必须根据项目所处的状态和各种条件,去综合权衡。我只有几十分钟时间去重新规划和整理100条数据。可能真的没法想太多。需求总是变化的,不知道明天又会变成什么样,可能再进一步,就变成”过度设计“了。另外,项目本身, intent 本身约定了自己特有命名规律,是可以安全认为 intent 和 分类一定不会重复的。

问题和视频关联

在读取 intent_info.js 中的足够可信的结构化数据后,我会动态建立问题和视频的关联。这个过程中,可能需要适当修改问题和视频的标题。为了避免遗漏,一个标题,如果没有对应的视频或对应多个视频,就直接crash。有些霸道,但总比后期一个一个比对排查,省太多事了。结合问题和视频标题的特点,我专门封装了一个方法:

/* 获取某个标题对应的本地路径.
为了避免未知错误,如果找不到或找到多个,就直接 crash.

@return  本地视频的相对路径.
 */
function localVideoPath(title)
{
  let path = require("path")
  let fs = require ('fs-plus')
  let fse = require('fs-extra')
  let os = require("os")
  let {execSync} = require("child_process")

  let videoDir = path.resolve(__dirname,"./videos")

  let videos = fs.listTreeSync(videoDir)
                  .filter(item=>{
                    return [".mov",".mp4"].includes(path.extname(item))
                  })
                  .map(item=>{
                    return path.relative(__dirname,item)
                  })

  /* 一个标题,能且只能对应一个视频,否则就抛出异常. */
  let localVideoPath = null

  for (let item of videos) {
    if (item.includes(title)) {
      if (localVideoPath) {
        const tip = `致命异常: ${title} 对应的视频重复:
        ${localVideoPath}
        ${item}`

        throw new Error(tip)
      }

      localVideoPath = item
    }
  }

  if (!localVideoPath) {
    const tip = `致命异常!这个标题竟然没有对应的视频:\n${title}`

    throw new Error(tip)
  }

  return localVideoPath
}

见码如唔

完整的自动化处理成结构数据的逻辑如下,都集中在 make_data.js 中。

/* 生成带有排序等信息的文件. */

/* 支持自动生成数据. */
makeDataWithOrder()
function makeDataWithOrder()
{
  const fs = require('fs-extra')
  const path = require('path')

  const intentInfo = require("./intent_info.js")

  let intentInfoNew = []
  let index = 1

  /* 在node中遍历时,key的顺序是和原始key的顺序对应的.
  这个特性,并不总是有效,比如在 ios 浏览器中.
  目前,仅仅是够用. */
  let category = ""
  for (let intent in intentInfo) {
    if (intentInfo[intent] == "_category") { /* 说明是一个分类标记. */
      category = intent
      continue
    }
    let title = intentInfo[intent]
    const local_path = localVideoPath(title)
    intentInfoNew.push({
      "type":"video",
      "content":"",
      "intent": intent,
      "title": title,
      "order": index,
      "local_video_path": local_path,
      "ext": path.extname(local_path),
      "category":category,
    })

    ++ index
  }

  localVideoLoseCheck(intentInfoNew)
  const dataPath = path.resolve(__dirname, "./data.json")
  fs.writeJsonSync(dataPath, intentInfoNew)
  console.log(`恭喜!数据已写入 ${dataPath}`)
}

/* 确保视频总数与intent总数是对应的,防止有视频遗漏.
有视频没有对应问题时,会直接抛出异常.
 */
function localVideoLoseCheck(intents)
{
  /* 先把视频信息处理成 key-value. */
  let path = require("path")
  let fs = require ('fs-plus')
  let fse = require('fs-extra')
  let os = require("os")
  let {execSync} = require("child_process")

  let videoDir = path.resolve(__dirname,"./videos")
  let videoDict = fs.listTreeSync(videoDir)
                  .filter(item=>{
                    return [".mov",".mp4"].includes(path.extname(item))
                  })
                  .map(item=>{
                    return path.relative(__dirname,item)
                  })
                  .reduce((sum,item,idx)=>{
                    sum[item] = false
                    return sum
                  },{})

  for (let item of intents) {
    videoDict[item.local_video_path] = true
  }

  /* 寻找缺失的. */
  let loses = []
  for (let item in videoDict) {
    if (!videoDict[item]) {
      loses.push(item)
    }
  }

  if (loses.length) {
    const tip = `一下 ${loses.length} 个视频没有对应的问题:
    ${JSON.stringify(loses)}`
    throw new Error(tip)
  }
}

/* 获取某个标题对应的本地路径.
为了避免未知错误,如果找不到或找到多个,就直接 crash.

@return  本地视频的相对路径.
 */
function localVideoPath(title)
{
  let path = require("path")
  let fs = require ('fs-plus')
  let fse = require('fs-extra')
  let os = require("os")
  let {execSync} = require("child_process")

  let videoDir = path.resolve(__dirname,"./videos")

  let videos = fs.listTreeSync(videoDir)
                  .filter(item=>{
                    return [".mov",".mp4"].includes(path.extname(item))
                  })
                  .map(item=>{
                    return path.relative(__dirname,item)
                  })

  /* 一个标题,能且只能对应一个视频,否则就抛出异常. */
  let localVideoPath = null

  for (let item of videos) {
    if (item.includes(title)) {
      if (localVideoPath) {
        const tip = `致命异常: ${title} 对应的视频重复:
        ${localVideoPath}
        ${item}`

        throw new Error(tip)
      }

      localVideoPath = item
    }
  }

  if (!localVideoPath) {
    const tip = `致命异常!这个标题竟然没有对应的视频:\n${title}`

    throw new Error(tip)
  }

  return localVideoPath
}

我们在项目目录执行

node ./make_data.js

就可以得到我们想要的结构化的数据:

[
  {
    "type": "video",
    "content": "",
    "intent": "smart_transform",
    "title": "【smart-transform】取自 Atom 的 babeljs:coffeescript:typescript 智能转 es5 库",
    "order": 1,
    "local_video_path": "videos/树莓派/【smart-transform】取自 Atom 的 babeljs:coffeescript:typescript 智能转 es5 库.mp4",
    "ext": ".mp4",
    "category": "树莓派"
  },
  {
    "type": "video",
    "content": "",
    "intent": "memory_leakDetector",
    "title": "【YFMemoryLeakDetector】人人都能理解的 iOS 内存泄露检测工具类",
    "order": 2,
    "local_video_path": "videos/树莓派/【YFMemoryLeakDetector】人人都能理解的 iOS 内存泄露检测工具类.mp4",
    "ext": ".mp4",
    "category": "树莓派"
  },
  {
    "type": "video",
    "content": "",
    "intent": "sinopia_npm",
    "title": "【玩转树莓派】使用 sinopia 搭建私有 npm 服务器",
    "order": 3,
    "local_video_path": "videos/树莓派/【玩转树莓派】使用 sinopia 搭建私有 npm 服务器.mp4",
    "ext": ".mp4",
    "category": "树莓派"
  },
  {
    "type": "video",
    "content": "",
    "intent": "frp_ip",
    "title": "【小技巧解决大问题】使用 frp 突破阿里云主机无弹性公网 IP 不能用作 Web 服务器的限制",
    "order": 4,
    "local_video_path": "videos/树莓派/【小技巧解决大问题】使用 frp 突破阿里云主机无弹性公网 IP 不能用作 Web 服务器的限制.mp4",
    "ext": ".mp4",
    "category": "树莓派"
  },
  {
    "type": "video",
    "content": "",
    "intent": "luajit_macos",
    "title": "【LuaJIT版】从零开始在 macOS 上配置 Lua 开发环境",
    "order": 5,
    "local_video_path": "videos/Lua/【LuaJIT版】从零开始在 macOS 上配置 Lua 开发环境.mp4",
    "ext": ".mp4",
    "category": "Lua"
  },
  {
    "type": "video",
    "content": "",
    "intent": "lua_macos",
    "title": "【最新版】从零开始在 macOS 上配置 Lua 开发环境",
    "order": 6,
    "local_video_path": "videos/Lua/【最新版】从零开始在 macOS 上配置 Lua 开发环境.mp4",
    "ext": ".mp4",
    "category": "Lua"
  },
  {
    "type": "video",
    "content": "",
    "intent": "app_future",
    "title": "关于混合应用开发的未来的一些思考",
    "order": 7,
    "local_video_path": "videos/Lua/关于混合应用开发的未来的一些思考.mp4",
    "ext": ".mp4",
    "category": "Lua"
  },
  {
    "type": "video",
    "content": "",
    "intent": "google_bug",
    "title": "记录我发现的第一个关于 Google 的 Bug",
    "order": 8,
    "local_video_path": "videos/Lua/记录我发现的第一个关于 Google 的 Bug.mp4",
    "ext": ".mp4",
    "category": "Lua"
  },
  {
    "type": "video",
    "content": "",
    "intent": "tip_rest",
    "title": "【树莓派自动化应用实例】整点提醒自己休息五分钟",
    "order": 9,
    "local_video_path": "videos/frp/【树莓派自动化应用实例】整点提醒自己休息五分钟.mp4",
    "ext": ".mp4",
    "category": "frp"
  },
  {
    "type": "video",
    "content": "",
    "intent": "frp_anywhere",
    "title": "借助 frp 随时随地访问自己的树莓派",
    "order": 10,
    "local_video_path": "videos/frp/借助 frp 随时随地访问自己的树莓派.mp4",
    "ext": ".mp4",
    "category": "frp"
  }
]

参考文章


iOS122
1.6k 声望117 粉丝