如何建立一个基于网络的徽章扫描器(附代码示例)

155 阅读7分钟

我现在正在一个会议上做展台工作(好吧,至少在我开始写这篇文章的时候),我真的没有意识到我是多么喜欢这部分工作。虽然在COVID之后我参加了一些会议(呃,好吧,"后 "这个词可能太强烈了),但这是我多年来第一次在展台工作。我到达时做的第一件事就是查看我们如何通过徽章扫描获得联系。毫不奇怪,会议组织者建议使用本地应用程序。我是我--我立即想到了该应用程序的功能如何通过网络来完成。本机应用程序没有任何问题(实际上,它有时是很有问题的),但我不喜欢为活动安装本机应用程序。十有八九我会忘记把它从我的手机上删除,尽管我永远不会再使用它。我现在已经建立了一个基于网络的应用程序,虽然它肯定丑得要命,但我想我应该分享我是如何做到的。

这个网络应用有以下特点:

  • 通过用户互动,启动一个摄像头,这样你就可以把它指向一个徽章,并扫描二维码。
  • 解析二维码的结果,让你持久地存储联系人。
  • 呈现联系人列表,以便你可以看到你已经扫描了谁。
  • 最后,让用户点击下载联系人的压缩文件。

让我们详细了解一下我是如何建立这些部分的。

二维码扫描器

对于应用程序的第一部分,我需要一个QR扫描仪。我知道一个网页可以访问用户的相机(通过getUserMedia ,一个我过去使用过的API),我知道它可以通过video 标签渲染到屏幕上。困难的部分是看那个流并试图找到一个QR码。

幸运的是,我遇到了一个伟大的库,它简化了大部分的工作:https://github.com/nimiq/qr-scanner。这个库可以处理获取摄像头的访问,在屏幕上显示,并试图找到和解析QR码。顺便提一下,有一个支持QR码的条形码检测的本地API,但它现在几乎是一个Chromium的东西。我使用的QR扫描器库将利用它,如果它存在的话。

在抓取了所需的JS库后,下面是我使用它的方法。首先,我在我的布局中开始使用一个video 标签:

<video id="cam" style="width:300px; height:300px;"></video>

在JavaScript中,有几个步骤。首先,我得到一个指向DOM元素的指针:

videoElem = document.querySelector('#cam');

接下来,我制作一个扫描器的实例:

qrScanner = new QrScanner(
    videoElem,
    scanResult,
    { returnDetailedScanResult: true },
);

scanResult 是一个成功的处理程序。为了开始扫描,你使用这个方法:

qrScanner.start();

对于我的应用程序,我把它与一个你可以点击的按钮联系起来,以开始扫描过程。成功处理程序被传递给一个对象,该对象将包含,令人惊讶的是,扫描的结果为文本。现在是有趣的部分。

解析结果

当我在这次会议上测试我的徽章时,QR码包含vCard信息。vCard字符串是一个有点简单格式的联系信息。(你可以在规范中阅读更多关于它的信息)。这里有一个例子(来源是docs.fileformat.com/email/vcf/)

BEGIN:VCARD
VERSION:2.1
N:Gump;Forrest;;Mr.
FN:Forrest Gump
ORG:Bubba Gump Shrimp Co.
TITLE:Shrimp Man
PHOTO;GIF:http://www.example.com/dir_photos/my_photo.gif
TEL;WORK;VOICE:(111) 555-1212
TEL;HOME;VOICE:(404) 555-1212
ADR;WORK;PREF:;;100 Waters Edge;Baytown;LA;30314;United States of America
LABEL;WORK;PREF;ENCODING#QUOTED-PRINTABLE;CHARSET#UTF-8:100 Waters Edge#0D#
 #0ABaytown\, LA 30314#0D#0AUnited States of America
ADR;HOME:;;42 Plantation St.;Baytown;LA;30314;United States of America
LABEL;HOME;ENCODING#QUOTED-PRINTABLE;CHARSET#UTF-8:42 Plantation St.#0D#0A#
 Baytown, LA 30314#0D#0AUnited States of America
EMAIL:forrestgump@example.com
REV:20080424T195243Z
END:VCARD

这不是一个非常困难的格式,我能够找到一些预建的JavaScript库,但它们都有点不稳定。我决定建立我自己的库,虽然它可能不是完全健壮的,但它能完成工作。我的目的是解析这些字段,并尽可能给它们起一个更好的名字。这是我写的函数。

function parseVCard(str) {
  let result = {};
  
  let fieldMap = {
    'N':'name',
    'FN':'fullname',
    'EMAIL':'email',
    'TITLE':'title',
    'ORG':'org',
    'EMAIL':'email',
    'ADR':'address',
    'TEL':'telephone',
    'VERSION':'version'
  }

  str = str.trim();
  str.split(/[\r\n]/).forEach(l => {
    let [ key, value ] = l.split(':');
    if(key === 'BEGIN' || key === 'END') return;

    // Ok, so unless key has ; in it, we're simple
    if(key.indexOf(';') === -1) {
        result[fieldMap[key]] = value.trim(); 
    } else {
      // So in theory, it will ALWAYS be type=
      let [newKey, newType] = key.split(';');
      // and type can be TYPE=(nothing), so let's just keep it simple
      newType = newType.replace('TYPE=','');
      /*
      so type should always be blank or a value, but I've seen FAX,FAX which isn't valid, 
      so I'm going to split and [0]
      */
      if(newType.length) {
        newType = newType.split(',')[0].toLowerCase();
      }
      result[fieldMap[newKey]] = {
        type:newType,
        value:value
      }
    }
  });
  
  return result;
}

在大多数情况下,这只是字符串解析,但请注意,联系人记录中的一些字段有类型,如地址和电话号码。这个函数的结果是一个漂亮的JavaScript对象,它是一个字段数组,有更好的名称、值,如果有的话,还有类型。

所以回到扫描操作上,我是这样处理的:

function scanResult(r) {
    qrScanner.stop();
    contact = parseVCard(r.data);
    contactOrig = r.data;
    resultElem.innerText = contact.name;
    addElem.removeAttribute('disabled');
}

我关闭当前的扫描器。解析数据并将其和原始字符串保存在一个全局变量中,然后更新DOM以反映一个新的扫描结果的到来。我使用名称值作为标签。

我有没有说过,用户界面并不漂亮?

所以,作为一个快速测试,我让我的两个最好的朋友把他们在最近的会议上的徽章的照片发给我。一个有VCard,一个没有,而是有一些其他奇怪的~限定格式:

12688~Scott~Stroz~noyb@noyb.com~MySQL Developer Advocate~Oracle~5559755049~12345

好吧,所以在这一点上,我的应用程序可以扫描徽章,解析vCard,现在我们需要保存它。

保存联系人

为了处理持久性,我决定使用IndexedDB。几年前,我曾深入研究过客户端存储。我写过帖子,做过演讲,甚至还写过一本书。但是,由于这个空间并没有什么变化(据我所知),我最近也没怎么用它。我肯定会在这个话题上做一些更新的文章,但现在,我使用了德克西图书馆。我计划在本月晚些时候写更多关于这个的博客,但这里有一个例子说明它是多么的酷。

首先,我设置了一个初始化的数据库:

contactsDb = new Dexie('contactsDb');
contactsDb.version(1).stores({contacts:'++id,contact.fullname'})

在第二行,我定义了一个名为contacts 的集合,有一个自动编号主键和一个关于联系人姓名的索引。我最终没有使用这个索引,但如果我需要它,它就在那里。这并不是我要保存的记录的每一部分的清单,只是与键和索引有关的重要信息。

为了实际保存我的数据,我是这样做的:

await contactsDb.contacts.put({ contact, originalContact:contactOrig, created:new Date() });

对,就是这样。我存储了 "漂亮 "的联系人,原始联系人,以及一个日期戳。但这就是字面上的意思。在我的应用程序中,我想渲染联系人。我从一个空表开始:

<table id="contactsTable">
    <thead>
    <tr>
        <th>Name</th>
        <th>Created</th>
    </tr>
    </thead>
    <tbody>
    </tbody>
</table>

然后像这样建立一个渲染函数:

// earlier in the code
tableElem = document.querySelector('#contactsTable tbody');

async function renderContacts() {
  let contacts = await contactsDb.contacts.toArray();
  let html = '';
  contacts.forEach(c => {
    html += `
    <tr>
      <td>${c.contact.fullname ?? c.contact.name}</td>
      <td>${dtFormat(c.created)}</td>
    </tr>`;
  });
  tableElem.innerHTML = html;
}

Dexie线是toArray() 的部分。很简单,比 "本地 "的 IndexedDB 调用容易得多。这是一个非常可爱的结果。

下载一个压缩文件

在应用程序的最后部分,我添加了一个按钮,它将启动一个进程来导出和保存联系人。我发现了一个很酷的库,JSZip。它可能是我见过的最简单的压缩库之一。当与另一个库FileSaver结合使用时,整个程序是这样的:

async function downloadContacts() {
  let zip = new JSZip();
  let contacts = await contactsDb.contacts.toArray();
  contacts.forEach(c => {
    let file = c.id + '.vcf';
    zip.file(file, c.originalContact);
  });

  zip.generateAsync({ type: 'blob' }).then(function (content) {
    saveAs(content, 'contacts.zip');
  });

}

我抓取联系人,进行迭代,根据主键给他们一个名字,然后直接生成并保存它。就这样了!