上个月,设计师 Helen Tran 要求人们列出五位他们认为对行业有价值的女设计师。
我需要你们的帮助。
列出五位你认为对我们行业真正有价值和重要的女性设计师。— Helen Tran (@tranhelen) 2017年4月26日
仅仅几天时间,这条推文就产生了超过 373 条回复,其中提到了 636 位女性,并明确列出了她们的 Twitter 用户名。有些人开玩笑说,这条帖子是寻找潜在招聘对象的好地方,我意识到我有一个不同的需求:我不想招聘任何人,但我希望找到一些优秀的女性关注,她们擅长我感兴趣的事情,或者我正在努力学习更多内容。由于许多人在他们的 Twitter 描述中都写了他们的职位或专业领域,我意识到我可以通过搜索每个描述中的关键词来创建一个自我报告的过滤系统,然后在一个可排序的目录中显示这些个人资料。

我决定将这条帖子作为起点,创建一个独立的网站:Women Who Design。 以下是我的构建方法。
开始
首先,我需要
- 从原始帖子中记录每个人的 Twitter 用户名(也称为“句柄”)到列表中。
- 设置项目的文件夹结构。
- 获取 Twitter REST API 以从给定的句柄返回个人资料信息。
- 选择一个数据库来存储每个句柄及其对应的个人资料信息。
第一步,获取句柄,是最简单的。 在背景中播放一个好的播放列表,我花了大约一个小时梳理 Twitter 帖子并将每个句柄输入到电子表格中,然后将其导出为名为 `designers.json` 的 JSON 文件。
此时,我初始化了我的 git 仓库并设置了一个基本的文件夹结构
- index.html
- app.js
- styles.css
- designers.json
在我的 `app.js` 文件的顶部,我导入了原始 Twitter 帖子中的所有设计师。
var designers = require('./designers.json');
接下来,我在 Twitter 上注册了我的应用程序以开始使用 REST API。

我选择将项目配置为只读应用程序,因为我只打算使用GET users/show 端点,它提供用户信息。

然后,我通过命令行安装了 Twitter 的异步客户端库(也称为 Twitter),以便能够向 API 发出请求。
npm install twitter
为了在我的项目中使用该库,我还必须在 `app.js` 文件的顶部引入它。
var twitter = require('twitter');
根据客户端库文档,在引入 "twitter"
后,我需要在我的 `.js` 文件中输入应用程序的消费者密钥、消费者密钥和承载令牌。
var client = new Twitter({
consumer_key: '',
consumer_secret: '',
bearer_token: ''
});
密钥和密钥很容易在我的 Twitter 应用程序仪表板中找到,但承载令牌需要额外的步骤。 我在命令行中运行以下命令以获取承载令牌,使用我从仪表板中获取的凭据填充变量,然后将结果添加到上面的客户端变量中。
curl -u "$CONSUMER_KEY:$CONSUMER_SECRET" \
--data 'grant_type=client_credentials' \
'https://api.twitter.com/oauth2/token'
客户端库还提供了一种方便的请求方法,因此我将其添加到我的 app.js 文件中,并附带一个稍后填写它的注释。 根据GET users/show 端点文档,我需要将列表中的每个句柄传递给 "screen_name"
参数以获取我正在寻找的个人资料信息。
client.get('users/show', {'screen_name': handle}, function(error, response) {
if (!error) {
console.log(response);
// do stuff here later!
}
});
如果操作正确,我预计响应将如下所示
{
"id": 2244994945,
"id_str": "2244994945",
"name": "TwitterDev",
"screen_name": "TwitterDev",
"location": "Internet",
"profile_location": null,
"description": "...",
最后,我必须选择一个数据库来存储个人资料。 我选择了 Firebase 的实时数据库,因为它是一个 NoSQL 数据库,使用 JSON 作为其存储格式。 我在 npm 上安装了 firebase
和 firebase-admin
,然后在 `app.js` 文件的顶部与其他所有内容一起引入它们。
var firebase = require('firebase');
var admin = require('firebase-admin');
为了使写入和读取数据库正常工作,我必须使用一个专门生成的“服务帐户”私钥对 Firebase 进行身份验证。 我在 Firebase 设置的服务帐户选项卡中生成了密钥,并将相应的代码放在我的其他配置下方。
var serviceAccount = {
"type": "service_account",
"project_id": process.env.WWD_FIREBASE_PROJECT_ID,
"private_key_id": process.env.WWD_FIREBASE_PRIVATE_KEY_ID,
"private_key": process.env.WWD_FIREBASE_PRIVATE_KEY,
"client_email": process.env.WWD_FIREBASE_CLIENT_EMAIL,
"client_id": process.env.WWD_FIREBASE_CLIENT_ID,
"auth_uri": "https://#/o/oauth2/auth",
"token_uri": "https://#/o/oauth2/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": process.env.WWD_FIREBASE_CLIENT_CERT_URL
};
处理数据
哇!列表记录:已完成。应用程序注册:已完成。文件夹结构设置:已完成。数据库设置:已完成。是时候真正开始使用 API 了。
我查看了GET users/show 端点文档中的示例响应,并确定我需要存储用户的姓名、句柄(在文档中称为 screen_name
)、位置、描述和个人资料图片 URL。 我还想保存每个用户在 Twitter 上设置的个人资料颜色,用作按钮、链接和其他装饰的突出显示颜色,所以我将以下代码放在我保存在 `app.js` 文件中的客户端库快捷方法中。
var name = response.name;
var handle = response.screen_name;
var description = response.description;
var imageUrl = response.profile_image_url_https;
var location = response.location;
var profileColor = response.profileColor;
为了防止大小写差异导致同一个个人资料出现两次(例如 @julesforrest 与 @JulesForrest),我将句柄变量设置为小写。
handle = handle.toLowerCase();
然后我注意到返回的个人资料图片太小,无法在桌面显示上使用。 通过调整图片 URL,我找到了一条更适合我需求的大图片路径。
imageUrl = response.profile_image_url_https.replace("_normal", "_400x400");
链接链接
人们经常在他们的个人资料描述中使用 URL、@句柄、#标签和电子邮件地址,但不幸的是,Twitter API 将每个描述都作为简单的字符串返回。 幸运的是,我发现了一个名为Autolinker的工具,它可以搜索每个字符串并在适当的位置构建锚标记。
为了使其正常工作,我通过 npm 安装了它,然后在 `app.js` 文件的顶部引入它。
npm install autolinker --save
var Autolinker = require( 'autolinker' );
基本用法看起来非常简单,并且它自带了一些开箱即用的选项,可以作为对象传递给第二个参数,例如在新窗口中打开每个链接或为每个锚标记添加一个类。
var linkedText = Autolinker.link( textToAutolink[, options] );
但是,我想将每个用户自定义的个人资料颜色(之前从 API 响应中保存)作为内联样式添加到该用户的链接上,这需要编写一个自定义替换函数。 虽然自定义替换函数可以处理一些非常复杂的配置,但我只稍微调整了文档中的示例以添加内联颜色样式并在新窗口中打开每个链接。 值得注意的是,需要在对象参数的顶部指定提及和标签的社交网络才能正确链接它们,这在文档中并不立即清楚。
description = Autolinker.link( description, {
mention: 'twitter',
hashtag: 'twitter',
replaceFn : function( match ) {
switch( match.getType() ) {
case 'url' :
var tag = match.buildTag();
tag.setAttr( 'style', 'color: #' + profileColor );
return tag;
case 'mention' :
var mention = match.getMention();
return `<a href="https://twitter.com/${mention}" target="blank" style="color: #${profileColor}">@${mention}</a>`;
case 'email' :
var email = match.getEmail();
return `<a href="mailto:"${email}" target="blank" style="color: #${profileColor}">${email}</a>`;
case 'hashtag' :
var hashtag = match.getHashtag();
return `<a href="https://twitter.com/hashtag/${hashtag}" target="blank" style="color: #${profileColor}">#${hashtag}</a>`;
}
}
});
令人沮丧的是,Twitter 的缩短链接 t.co URL 作为锚标记的文本显示,而不是描述性 URL。

经过几个小时的调试,我终于注意到,t.co URL 而不是描述性 URL 一直存在于 API 返回的原始字符串中。 重新检查示例响应后,我发现了一个我之前错过的 description.urls
对象,并记录了它,用相应的描述性 URL 文本替换了 t.co URL 文本。
var descriptionUrls = response.entities.description.urls;
if (descriptionUrls.length != 0) {
for (var i = 0; i < descriptionUrls.length; ++i) {
description = description.replace(descriptionUrls[i].url, `${descriptionUrls[i].display_url}`);
}
}
搜索过滤器
人们在关注与他们相关的人时才能充分利用 Twitter,因此目录用户必须能够轻松地按职位或专业领域对个人资料进行排序。 例如,作为一名对前端开发感兴趣的人,我想找到目录中哪些女性将自己定义为开发者。

添加这些可排序的过滤器是在将每个个人资料写入 Firebase 之前要完成的最后也是最重要的一步,我花了大量时间思考解决这个问题的正确方法。 我知道我可以通过搜索每个个人资料描述中的关键词来创建一个自我报告的过滤系统,但许多人在描述中使用的术语相互重叠(产品设计师和 UX 设计师、开发者和工程师)。 最终,我决定使用人们用来描述自己的确切术语非常重要,即使这意味着目录用户需要点击更多次。
为了选择过滤器类别,我查找了在所有描述中出现频率最高的术语,并编写了一个函数来搜索术语并将相应的标签推送到数组中。 我计划稍后在前端使用该数组来实现过滤功能。
var designerTagsArray = [];
function addDesignerTags(handle, searchTerm, tag) {
if ((description.toUpperCase()).includes(searchTerm) === true) {
designerTagsArray.push(tag);
};
};
addDesignerTags(handle, "PRODUCT DESIGN", "product ");
addDesignerTags(handle, "LEAD ", "lead ");
addDesignerTags(handle, "MANAGER", "manager ");
// etc, etc
对于某些术语,例如“总监”,我必须进行自定义搜索以剔除具有显着不同含义的类似短语,例如艺术总监或创意总监。
if ((description.toUpperCase()).includes("DIRECTOR") === true) {
if ((description.toUpperCase()).includes("ART DIRECTOR") === true) {
// do nothing
} else if ((description.toUpperCase()).includes("CREATIVE DIRECTOR") === true) {
// do nothing
}
else {
designerTagsArray.push("director");
};
};
完成过滤后,我将数组字符串化并删除任何多余的字符。
designerTagsArray = JSON.stringify(designerTagsArray);
designerTagsArray = designerTagsArray.replace(/[^\w\s]/gi, '');
写入 Firebase
是时候上传原始列表中的所有数据了。 首先,我创建了一个新对象来保存我需要写入 Firebase 的所有信息。
var designerProfile = new Object();
分配所有项目
designerProfile.name = name;
designerProfile.handle = handle;
designerProfile.description = description;
designerProfile.imageUrl = imageUrl;
designerProfile.imageUrlMobile = imageUrlMobile;
designerProfile.profileColor = profileColor;
designerProfile.designerTags = designerTagsArray;
并编写了一个函数将它们添加到我在 Firebase 上名为 display
的对象中。
function writeToFirebase(handle, designerProfile) {
firebase.database().ref('display/' + handle).set({
designerProfile
});
console.log(handle + " has been written to Firebase!");
};
writeToFirebase(handle, designerProfile);
到目前为止,我编写的所有代码都包含在 Twitter 客户端库的原始快捷方法中。
client.get('users/show', {'screen_name': handle}, function(error, response) {
if (!error) {
console.log(response);
// log relevant data
// lowercase handle
// adjust image URL
// add links to descriptions
// search and add tags
// write to Firebase
}
});
我将快捷方法包装在一个名为 getProfileInfo
的函数中,该函数接受 Twitter 句柄作为参数。
var getProfileInfo = function(handle) {
client.get('users/show', {'screen_name': handle}, function(error, response) {
if (!error) {
console.log(response);
// log relevant data
// lowercase handle
// adjust image URL
// add links to descriptions
// search for tags
// write to Firebase
}
});
};
然后我写了一个循环,遍历来自原始列表的 JSON 文件中的每个句柄,我之前已将其导入到 app.js 文件的顶部。
for (var i = 0; i < designers.length; ++i) {
getProfileInfo(designers[i].handle);
};
最后,我在命令行中使用 Node 运行脚本,并将所有个人资料数据显示在 Firebase 中。
node app.js

前端
在解决数据问题的同时,我还开发了一个简单的前端,它从数据库读取个人资料,并使用 jQuery 以 HTML 格式构建它们。我还创建了关于页面和提名页面,并在提名页面上提供了一个表单来捕获新的提交内容。

为了使表单正常工作,我从每个输入字段中获取输入的文本,并将它们添加到一个名为 submit
的新 Firebase 对象中,以便以后审查。
var designerDatabase = firebase.database();
$('#designer-submission').submit(function(event){
event.preventDefault();
var handle = $('#handle').val();
var reason = $('#reason').val();
designerDatabase.ref('submissions/' + handle).set({
handle: handle,
reason: reason
});
$('#handle').val('');
$('#reason').val('');
});
总而言之,我最终得到了一个客户端的 `.js` 文件、三个 `.html` 文件、一个 logo `.svg` 文件和一个 `.css` 文件。
上线
当所有基本交互都编码完成后,我决定是时候尝试将项目部署到 Heroku 上了。由于应用程序的大部分构建在服务器端 Node 上,因此我需要一个名为 Express.js 的工具将其发布为一个实际的站点。为此,我必须设置我的 `package.json` 文件。
npm init
在询问了一些关于我的应用程序名称和版本号的问题后,它提示我指定一个入口点,我将其保留为默认值:index.js
。
entry point: (index.js)
然后我安装了 Express
npm install express --save
之后,我设置了我的 index.js 文件,它看起来像这样
var express = require('express');
var app = express();
app.use(express.static('public'));
app.get('/', function (req, res) {
res.sendFile('index.html');
});
app.listen(process.env.PORT || 3000, function () {
console.log('Example app listening on port 3000!');
});
为了正确地提供所有客户端文件,我将它们全部移动到一个名为 public
的文件夹中。我设置了一个远程 Heroku 仓库并将代码推送到该仓库。
git push heroku master
调整后端结构
一旦其他所有东西都运行起来,我不得不稍微更改一下我的设置,因为我想使用 getProfileInfo
函数来刷新现有的个人资料,并为通过网站提交的设计师编写全新的个人资料。
我取消了我的 app.js 文件,并将 getProfileInfo
函数保存为名为 getprofileinfo.js<code>
的 module.export,以便在两个新创建的脚本中使用:display.js
和 submit.js。然后我在 display 脚本的顶部 require 了该模块,并使用了 Heroku 调度程序插件,每 24 小时运行一次以刷新 Firebase 上现有个人资料的数据。
var getProfileInfo = require('./getprofileinfo.js');
function getDisplayedDesigners() {
firebase.database().ref('display/').on('value', function (results) {
var allDisplayedDesigners = results.val();
for (var designer in allDisplayedDesigners) {
getProfileInfo(designer);
};
});
}
getDisplayedDesigners();

submit 脚本略有不同。我想手动浏览提交内容,删除任何恶意回复或不合格的人,然后自动将剩余的提交内容添加到 display 对象中,并从 submit 对象中删除它们。我还必须考虑在提交时在表单的句柄字段中可能包含 @ 符号的人。
var getProfileInfo = require('./getprofileinfo.js');
function getSubmittedDesigners() {
firebase.database().ref('submissions/').on('value', function (results) {
var allSubmittedDesigners = results.val();
for (var designer in allSubmittedDesigners) {
var handle = designer;
if (handle.includes("@") === true) {
handle = handle.replace("@", "");
getProfileInfo(handle);
firebase.database().ref('submissions/' + "@" + handle).remove();
} else {
getProfileInfo(handle);
firebase.database().ref('submissions/' + handle).remove();
};
};
});
}
getSubmittedDesigners();
通过这种设置,我可以使用命令行运行 submit.js,并一次处理所有合格的提交内容。
node submit.js
发布!
5 月 15 日,我正式发布了我的第一个应用程序:Women Who Design。在最初的 24 小时内,它获得了 15,000 次访问量和 1,000 个新的提名,这让我感到非常兴奋。不过,我并没有预料到这种反响,因此我现在正在进行一些主要的 front-end 和性能升级,以支持网站流量和个人资料数量。与此同时,我很高兴人们正在使用该网站来寻找和提名设计行业中才华横溢的女性。敬请期待!
感谢您撰写本文!期待看到接下来会发生什么。
Jules,这真是个糟糕的评论,但您在这篇文章上的工作很棒。我只是在尝试使用这种方法设置渐进式 Web 应用程序的基础知识,您所经历的步骤和细节让我意识到我完全以错误的方式处理事情。
我也很想看到关于性能方面改进的后续内容。
我也想看看您做了哪些性能改进。感谢您抽出时间撰写这篇很棒的文章!
这真是太棒了,Jules,干得好!这确实向您展示了构建 Web 应用程序是多么容易。
有时(大多数时候?)最简单的想法是最好的!这是一个很棒的想法,并且执行得很好,感谢您描述性的文章!
嘿,Jules,非常感谢您撰写本文。我刚刚按照您的教程操作(诚然,并非全部),并最终使用 Firebase 构建了我的第一个 Node 应用程序!
这是您制作的网站的劣质版本,但灵感来自它:https://designers-who-code.herokuapp.com/
感谢您提供此信息!
我有一个关于 Autolinker 的问题:我遇到了一个奇怪的错误,如果我添加了 replaceFn 方法,则提及/主题标签链接将不再自动链接。奇怪吧?
哇,看起来很棒,Andric。很高兴您发现本教程很有用。再次查看 Autolinker 部分,它已更新 :)
希望这能解决问题!
感谢您撰写这篇很棒的文章,Jules。它确实帮助我将很多点连接起来!