从今天开始,也是已婚人士了~

从今天开始,也是已婚人士了~
在开发短链助手的时候,我需要实现一个查看当前用户创建的所有短链接的能力。这个依然希望通过消息卡片来完成。而作为一个 JSON,想要构建一套合适的内容,就变得十分的麻烦和复杂。
我要发送的消息卡片当中,可以区分为动态内容和静态内容,对于静态内容,我可能长期都不会变化,而静态内容,则会根据用户的数据发生变化。
如果整体都放在代码中生成,我就需要有一段又臭又长的代码来维护其中的变化的 JSON ,而我希望整个代码的简洁,不要有比较长的代码只是用来生成卡片的逻辑,所以就用上了消息卡片的新功能:循环对象数组。
而进一步看动态内容,则我们可以将其视为是变量 A 和变量 B 在不断的被重复赋予,最终形成了一行一行的结果。
而我们想要实现这样功能。首先,需要在卡片搭建工具中创建一个循环对象数组,并将其绑定在一个「多列布局」上。
绑定完成后,你的多列布局就有了被循环的可能性。
接下来你需要在多列布局中去构建你的每一行的结果,并在对应的位置绑定上变量,比如我这里就给多列布局防止了一个 Markdown 文本组件,并在这个文本组件中,填入了 ${source}
作为变量 A 进行填充。
当你根据你的需要,构建出需要的卡片结构后,点击右上角的保存并发布,就可以准备写代码来实现批量发送数据的逻辑了。
这里的逻辑不复杂,首先需要从数据库中提取出需要用用作列表循环的数据,这里以 data.data 为例,data.data 是一个包含了 Object 的 Array,其中每一个 Object 都有 Postfix 和 Link 两个字段。这两个字段就是我们稍后要塞在卡片中的。
// data.data = [{Postfix:"a",Link:"https://amazon.cn"}]
let links = data.data.map(item => {
return {
source: `[${item.Postfix}](https://link.feishu.io/${item.Postfix})`,
target: item.Link
}
})
await client.request({
method: "POST",
url: "https://open.feishu.cn/open-apis/im/v1/messages",
data: {
receive_id: ctx.body.event.operator.operator_id.open_id,
msg_type: 'interactive',
content: JSON.stringify({
"type": "template",
"data": {
"template_id": "ctp_AAmFBm5vnHfs",
"template_variable": {
"CONTENT": links
}
}
}),
},
params: {
receive_id_type: 'open_id',
},
})
Code language: JavaScript (javascript)
最终我们构建出来,发给飞书服务器的 JSON 其实是这样子的,这段 JSON 就会和我们在卡片搭建工具中构建的 JSON 租和,自动进行拼接,从而实现我们想要的循环效果。
{
"type": "template",
"data": {
"template_id": "ctp_AAmFBm5vnHfs",
"template_variable": {
"CONTENT": [
{
"source":"a",
"target":"https://amazon.cn"
},
{
"source":"b",
"target":"https://baidu.com"
}
]
}
}
}
Code language: JSON / JSON with Comments (json)
构建出的卡片 JSON 是这样的,方便你参考:
{
"elements": [
{
"tag": "markdown",
"content": "你创建的链接如下:"
},
{
"tag": "column_set",
"flex_mode": "none",
"background_style": "grey",
"columns": [
{
"tag": "column",
"width": "weighted",
"weight": 1,
"vertical_align": "top",
"elements": [
{
"tag": "div",
"text": {
"content": "${source}",
"tag": "lark_md"
}
}
]
},
{
"tag": "column",
"width": "weighted",
"weight": 4,
"vertical_align": "top",
"elements": [
{
"tag": "div",
"text": {
"content": "${target}",
"tag": "lark_md"
}
}
]
}
],
"_varloop": "${CONTENT}"
}
],
"header": {
"template": "turquoise",
"title": {
"content": "链接清单",
"tag": "plain_text"
}
},
"card_link": {
"url": "",
"pc_url": "",
"android_url": "",
"ios_url": ""
}
}
Code language: JSON / JSON with Comments (json)
import cloud from '@lafjs/cloud'
import axios from 'axios'
let appid = "";
let secret = ""
const lark = require('@larksuiteoapi/node-sdk');
const client = new lark.Client({
appId: appid,
appSecret: secret
});
export default async function (ctx: FunctionContext) {
console.log("event",ctx.body);
if (ctx.body.challenge) {
return ctx.body
}
if (Object.hasOwn(ctx.body, "action") && ctx.body.action) {
if (ctx.body.action.name != "submit") return { code: 1 };
try {
// function to create link
if (status == 200) {
return JSON.stringify({
"type": "template",
"data": {
"template_id": "ctp_AAmFBm5vnlt0",
"template_variable": {
"source": ctx.body.action.form_value.postfix,
"target": ctx.body.action.form_value.link
}
}
})
}
return {};
} catch (e) {
return JSON.stringify({
"type": "template",
"data": {
"template_id": "ctp_AAmFBm5vZYuo",
"template_variable": {
"POSTFIX": ctx.body.action.form_value.postfix
}
}
});
}
}
if (Object.hasOwn(ctx.body, "header") && ctx.body.header.event_type == 'application.bot.menu_v6') {
// 处理按钮
if (ctx.body.event.event_key == "help") {
try {
let content = JSON.stringify({
template_id: "ctp_AAmFBFOpYX0S"
});
await client.request({
method: "POST",
url: "https://open.feishu.cn/open-apis/im/v1/messages",
data: {
receive_id: ctx.body.event.operator.operator_id.open_id,
msg_type: 'interactive',
content: JSON.stringify({
"type": "template",
"data": {
"template_id": "ctp_AAmFBFOpYX0S",
}
}),
},
params: {
receive_id_type: 'open_id',
},
})
return {};
} catch (e) {
console.log(`key: ${ctx.body.event.event_key}, user:${ctx.body.event.operator.operator_id.open_id},error`, e);
return {};
}
}
if (ctx.body.event.event_key == "mylink") {
try {
// function to get all my link
let links = data.data.map(item => {
return {
source: `[${item.Postfix}](https://link.feishu.io/${item.Postfix})`,
target: item.Link
}
})
await client.request({
method: "POST",
url: "https://open.feishu.cn/open-apis/im/v1/messages",
data: {
receive_id: ctx.body.event.operator.operator_id.open_id,
msg_type: 'interactive',
content: JSON.stringify({
"type": "template",
"data": {
"template_id": "ctp_AAmFBm5vnHfs",
"template_variable": {
"CONTENT": links
}
}
}),
},
params: {
receive_id_type: 'open_id',
},
})
return {};
} catch (e) {
console.log(`key: ${ctx.body.event.event_key}, user:${ctx.body.event.operator.operator_id.open_id},error`, e);
return {};
}
}
if (ctx.body.event.event_key == "create") {
try {
await client.request({
method: "POST",
url: "https://open.feishu.cn/open-apis/im/v1/messages",
data: {
receive_id: ctx.body.event.operator.operator_id.open_id,
msg_type: 'interactive',
content: "{\"header\":{\"template\":\"turquoise\",\"title\":{\"content\":\"创建短链接\",\"tag\":\"plain_text\"}},\"elements\":[{\"tag\":\"form\",\"name\":\"form_1\",\"elements\":[{\"tag\":\"input\",\"name\":\"postfix\",\"placeholder\":{\"tag\":\"plain_text\",\"content\":\"请输入后缀\"},\"max_length\":10,\"label\":{\"tag\":\"plain_text\",\"content\":\"请输入后缀:\"},\"label_position\":\"left\",\"value\":{\"k\":\"v\"}},{\"tag\":\"input\",\"name\":\"link\",\"placeholder\":{\"tag\":\"plain_text\",\"content\":\"请输入要跳转链接\"},\"label\":{\"tag\":\"plain_text\",\"content\":\"请输入要跳转链接:\"},\"label_position\":\"left\",\"value\":{\"k\":\"v\"}},{\"action_type\":\"form_submit\",\"name\":\"submit\",\"tag\":\"button\",\"text\":{\"content\":\"提交\",\"tag\":\"lark_md\"},\"type\":\"primary\",\"confirm\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"创建短链接\"},\"text\":{\"tag\":\"plain_text\",\"content\":\"确认提交吗\"}}}]}]}",
},
params: {
receive_id_type: 'open_id',
},
})
return {};
} catch (e) {
console.log(`key: ${ctx.body.event.event_key}, user:${ctx.body.event.operator.operator_id.open_id},error`, e);
return {};
}
}
}
return { data: 'hi, laf' }
}
Code language: JavaScript (javascript)
在开发短链助手时,一个很大的痛苦的点是我希望通过消息卡片来完成开发者的交互,这意味着我需要有大量的行为是和消息卡片来完成的。而消息卡片又不同于 HTML,是一个比较明确的 DSL。消息卡片更多是基于 JSON 提供的一套 Schema,将其放在代码中管理也是一个非常麻烦的事情。
好在最近飞书开放平台迭代了消息卡片模板的功能,我可以不用把 JSON 存在代码中,而是只在代码中存一个 Template ID ,从而降低我在代码中维护这段 JSON 的难度。
首先,你需要打开消息卡片搭建工具,并在其中创建一个新的卡片(你可以使用其提供的卡片组的能力,来管理你的卡片们)。比如我就要这个卡片组来管理短链助手和其他场景的卡片。
创建卡片完成后,你可以在 UI 上点击保存并发布,你就将你的卡片消息模板发布到了飞书的服务器。
此时,你就可以在代码中使用了。点击页面中间的 ID,复制消息卡片模板 ID,将你的调用代码替换为对应的逻辑即可。
使用模板需要注意,将消息卡片中的 Content 从过去的卡片内容,替换为 template 的 JSON。比如,使用卡片 JSON 发送的时候,我们发送的数据可能是这样的:
{
"receive_id": "oc_820faa21d7ed275b53d1727a0feaa917",
"content": "{\"config\":{\"wide_screen_mode\":true},\"elements\":[{\"alt\":{\"content\":\"\",\"tag\":\"plain_text\"},\"img_key\":\"img_7ea74629-9191-4176-998c-2e603c9c5e8g\",\"tag\":\"img\"},{\"tag\":\"div\",\"text\":{\"content\":\"你是否曾因为一本书而产生心灵共振,开始感悟人生?\\n你有哪些想极力推荐给他人的珍藏书单?\\n\\n加入 **4·23 飞书读书节**,分享你的**挚爱书单**及**读书笔记**,**赢取千元读书礼**!\\n\\n📬 填写问卷,晒出你的珍藏好书\\n😍 想知道其他人都推荐了哪些好书?马上[入群围观](https://open.feishu.cn/)\\n📝 用[读书笔记模板](https://open.feishu.cn/)(桌面端打开),记录你的心得体会\\n🙌 更有惊喜特邀嘉宾 4月12日起带你共读\",\"tag\":\"lark_md\"}},{\"actions\":[{\"tag\":\"button\",\"text\":{\"content\":\"立即推荐好书\",\"tag\":\"plain_text\"},\"type\":\"primary\",\"url\":\"https://open.feishu.cn/\"},{\"tag\":\"button\",\"text\":{\"content\":\"查看活动指南\",\"tag\":\"plain_text\"},\"type\":\"default\",\"url\":\"https://open.feishu.cn/\"}],\"tag\":\"action\"}],\"header\":{\"template\":\"turquoise\",\"title\":{\"content\":\"📚晒挚爱好书,赢读书礼金\",\"tag\":\"plain_text\"}}}",
"msg_type": "interactive"
}
Code language: JSON / JSON with Comments (json)
而在使用模板时,我们只需要很短的内容就可以:
{
"receive_id": "ou_7d8a6exxxxccs",
"msg_type": "interactive",
"content": "{\"type\": \"template\", \"data\": { \"template_id\": \"ctp_xxxxxxxxxxxx\", \"template_variable\": {\"article_title\": \"这是文章标题内容\"} } }"
}
Code language: JSON / JSON with Comments (json)
这样你就可以把过去又臭又长的 JSON 变为一个简短小巧的 Template ID 来完成。
在使用模板时,如果你的模板比较多,那么管理这些模板会比较成问题,一个比较好的办法是你可以考虑把 template ID 的编辑链接放在你的代码注释里,这样当你需要编辑 JSON 的时候,只需要点击代码中的链接就可以跳过来编辑了。
client.request({
method: "POST",
url: "https://open.feishu.cn/open-apis/im/v1/messages",
data: {
receive_id: ctx.body.event.operator.operator_id.open_id,
msg_type: 'interactive',
content: JSON.stringify({
"type": "template",
"data": {
"template_id": "ctp_AAmFBFOpYX0S", // https://open.feishu.cn/tool/cardbuilder?templateId=ctp_AAmFBFOpYX0S
}
}),
},
params: {
receive_id_type: 'open_id',
},
})
Code language: CSS (css)
飞书开放平台最近开始内测了输入框的能力,基于输入框,为消息卡片提供了进一步业务系统打通的可能性,你可以不需要开发一整个网页应用,只需要借助飞书机器人和飞书消息卡片,就可以实现一套业务交互逻辑。
这里首先确定要实现的逻辑:这里我要做的是一个短链接应用,功能很简单,点击下方的机器人菜单,并在弹出的窗口中输入对应的短链接后缀和要跳转的链接,点击确定就会帮我创建一个短链接。
具体效果如下:
如果后缀已经被占用,则展示如下内容:
在实现这个功能时,我首先使用了飞书提供的输入框组件的能力和表单组件能力,来实现整个业务交互,当然,你也可以根据业务形态,来选择合适的组件,构成一整个输入表单。
整体的功能可以分为三步:
接下来就是具体的实现步骤了。
首先,我先是使用了机器人的菜单功能,来实现在机器人底部配置菜单。你需要访问飞书开发者后台,找到机器人能力中的「机器人自定义菜单」,就可以配置一个机器人的自定义菜单了。机器人菜单支持跳转到指定链接,或者是推送事件,我选择推送事件,这样我就可以在服务端响应用户的创建的行为。这里我设定了事件内容为 create ,便于后续处理。
机器人菜单的处理则可以参考机器人菜单使用说明,通过订阅「机器人自定义事件」来完成对于相应行为的接受和对应的处理。
这部分的处理逻辑可以参考如下代码
// 判断请求体当中是否有 header 字段 && 来源的事件是否是机器人菜单
if (Object.hasOwn(ctx.body, "header") && ctx.body.header.event_type == 'application.bot.menu_v6') {
// 请求的事件是否是创建短链接对应的事件。
if (ctx.body.event.event_key == "create") {
try {
await client.request({
method: "POST",
url: "https://open.feishu.cn/open-apis/im/v1/messages",
data: {
receive_id: ctx.body.event.operator.operator_id.open_id, // 从事件体中提取事件的触发人
msg_type: 'interactive',
content: "", // 推送卡片 JSON
},
params: {
receive_id_type: 'open_id',
},
})
return {};
} catch (e) {
console.log(`key: ${ctx.body.event.event_key}, user:${ctx.body.event.operator.operator_id.open_id},error`, e);
return {};
}
}
}
Code language: JavaScript (javascript)
在完成卡片响应的设定后,接下来我实现的是校验的逻辑,这里分为两层:第一层是客户端可以完成的校验:比如短链接应该少于 10 个字符。第二层是只有客户端才能完成的校验。
如果每次发起请求都需要发送到服务端进行校验,则有比较高的校验成本。好在消息卡片提供了本地校验的能力,你可以通过 max_length
字段来验证输入框长短.
这里我是使用输入框组件的字段,来验证输入的内容长度不得大于 10 。
在消息卡片的输入框组件中,只要输入内容就会发现校验,因此我不能直接使用输入框组件,而是需要借助 form 组件,来实现用户输入两个内容再手动发起提交。则具体我构建的卡片 JSON 是这样的。
{
"header": {
"template": "turquoise",
"title": {
"content": "创建短链接",
"tag": "plain_text"
}
},
"elements": [
{
"tag": "form",
"name": "form_1",
"elements": [
{
"tag": "input",
"name": "postfix",
"placeholder": {
"tag": "plain_text",
"content": "请输入后缀"
},
"max_length": 10,
"label": {
"tag": "plain_text",
"content": "请输入后缀:"
},
"label_position": "left",
"value": {
"k": "v"
}
},
{
"tag": "input",
"name": "link",
"placeholder": {
"tag": "plain_text",
"content": "请输入要跳转链接"
},
"label": {
"tag": "plain_text",
"content": "请输入要跳转链接:"
},
"label_position": "left",
"value": {
"k": "v"
}
},
{
"action_type": "form_submit",
"name": "submit",
"tag": "button",
"text": {
"content": "提交",
"tag": "lark_md"
},
"type": "primary",
"confirm": {
"title": {
"tag": "plain_text",
"content": "创建短链接"
},
"text": {
"tag": "plain_text",
"content": "确认提交吗"
}
}
}
]
}
]
}
Code language: JSON / JSON with Comments (json)
这部分的关键是用 form 组件包裹 Input 组件,从而规避了 Input 组件输入内容就会发送到服务端校验的问题。
这部分逻辑我在实现的时候相对简单,没有专门去进行校验(主要是因为我的短链接服务和机器人是两个不同的服务),而是通过短链服务返回 200 还是 401 来判断是否出现了重复的问题,所以这里只是简单的使用了一个 try catch 来完成校验。
需要注意的是,这里你会注意到,返回是直接返回了一段 JSON String,这是因为触发这个事件是通过消息卡片的回调能力,如果你在消息卡片的回调能力返回一个 JSON,就会直接把 UI 层面的卡片渲染为你返回的卡片结果。靠着这个功能,我来实现的成功与失败返回不同的内容。
if (Object.hasOwn(ctx.body, "action") && ctx.body.action) {
try {
// create link
if (status == 200) {
return JSON.stringify({
"type": "template",
"data": {
"template_id": "ctp_AAmFBm5vnlt0",
"template_variable": {
"source": ctx.body.action.form_value.postfix,
"target": ctx.body.action.form_value.link
}
}
})
}
return {};
} catch (e) {
return JSON.stringify({
"type": "template",
"data": {
"template_id": "ctp_AAmFBm5vZYuo",
"template_variable": {
"POSTFIX": ctx.body.action.form_value.postfix
}
}
});
}
}
Code language: JavaScript (javascript)
整个机器人的部分的代码只有 170 余行,不多,供你参考
import cloud from '@lafjs/cloud'
import axios from 'axios'
let appid = "";
let secret = ""
const lark = require('@larksuiteoapi/node-sdk');
const client = new lark.Client({
appId: appid,
appSecret: secret
});
export default async function (ctx: FunctionContext) {
console.log("event",ctx.body);
if (ctx.body.challenge) {
return ctx.body
}
if (Object.hasOwn(ctx.body, "action") && ctx.body.action) {
if (ctx.body.action.name != "submit") return { code: 1 };
try {
// function to create link
if (status == 200) {
return JSON.stringify({
"type": "template",
"data": {
"template_id": "ctp_AAmFBm5vnlt0",
"template_variable": {
"source": ctx.body.action.form_value.postfix,
"target": ctx.body.action.form_value.link
}
}
})
}
return {};
} catch (e) {
return JSON.stringify({
"type": "template",
"data": {
"template_id": "ctp_AAmFBm5vZYuo",
"template_variable": {
"POSTFIX": ctx.body.action.form_value.postfix
}
}
});
}
}
if (Object.hasOwn(ctx.body, "header") && ctx.body.header.event_type == 'application.bot.menu_v6') {
// 处理按钮
if (ctx.body.event.event_key == "help") {
try {
let content = JSON.stringify({
template_id: "ctp_AAmFBFOpYX0S"
});
await client.request({
method: "POST",
url: "https://open.feishu.cn/open-apis/im/v1/messages",
data: {
receive_id: ctx.body.event.operator.operator_id.open_id,
msg_type: 'interactive',
content: JSON.stringify({
"type": "template",
"data": {
"template_id": "ctp_AAmFBFOpYX0S",
}
}),
},
params: {
receive_id_type: 'open_id',
},
})
return {};
} catch (e) {
console.log(`key: ${ctx.body.event.event_key}, user:${ctx.body.event.operator.operator_id.open_id},error`, e);
return {};
}
}
if (ctx.body.event.event_key == "mylink") {
try {
// function to get all my link
let links = data.data.map(item => {
return {
source: `[${item.Postfix}](https://link.feishu.io/${item.Postfix})`,
target: item.Link
}
})
await client.request({
method: "POST",
url: "https://open.feishu.cn/open-apis/im/v1/messages",
data: {
receive_id: ctx.body.event.operator.operator_id.open_id,
msg_type: 'interactive',
content: JSON.stringify({
"type": "template",
"data": {
"template_id": "ctp_AAmFBm5vnHfs",
"template_variable": {
"CONTENT": links
}
}
}),
},
params: {
receive_id_type: 'open_id',
},
})
return {};
} catch (e) {
console.log(`key: ${ctx.body.event.event_key}, user:${ctx.body.event.operator.operator_id.open_id},error`, e);
return {};
}
}
if (ctx.body.event.event_key == "create") {
try {
await client.request({
method: "POST",
url: "https://open.feishu.cn/open-apis/im/v1/messages",
data: {
receive_id: ctx.body.event.operator.operator_id.open_id,
msg_type: 'interactive',
content: "{\"header\":{\"template\":\"turquoise\",\"title\":{\"content\":\"创建短链接\",\"tag\":\"plain_text\"}},\"elements\":[{\"tag\":\"form\",\"name\":\"form_1\",\"elements\":[{\"tag\":\"input\",\"name\":\"postfix\",\"placeholder\":{\"tag\":\"plain_text\",\"content\":\"请输入后缀\"},\"max_length\":10,\"label\":{\"tag\":\"plain_text\",\"content\":\"请输入后缀:\"},\"label_position\":\"left\",\"value\":{\"k\":\"v\"}},{\"tag\":\"input\",\"name\":\"link\",\"placeholder\":{\"tag\":\"plain_text\",\"content\":\"请输入要跳转链接\"},\"label\":{\"tag\":\"plain_text\",\"content\":\"请输入要跳转链接:\"},\"label_position\":\"left\",\"value\":{\"k\":\"v\"}},{\"action_type\":\"form_submit\",\"name\":\"submit\",\"tag\":\"button\",\"text\":{\"content\":\"提交\",\"tag\":\"lark_md\"},\"type\":\"primary\",\"confirm\":{\"title\":{\"tag\":\"plain_text\",\"content\":\"创建短链接\"},\"text\":{\"tag\":\"plain_text\",\"content\":\"确认提交吗\"}}}]}]}",
},
params: {
receive_id_type: 'open_id',
},
})
return {};
} catch (e) {
console.log(`key: ${ctx.body.event.event_key}, user:${ctx.body.event.operator.operator_id.open_id},error`, e);
return {};
}
}
}
return { data: 'hi, laf' }
}
Code language: JavaScript (javascript)
Golang 作为一个编译型语言,在编写程序时,一个不太方便的点便是每次修改完代码,都需要重新编译才能测试效果。虽然你可以使用 go run main.go
命令来运行一个 go 文件,但由于项目往往文件比较多、修改时还是需要手动输入命令比较麻烦,所以给 Golang 的开发过程带来了不少的问题。
fresh 就是一个帮助你执行一些重复命令的命令行工具,有了 fresh ,你就可以不用自己手动执行 go run main.go
,它在检测到文件发上了变化后,会自动帮你中断掉当前进程,并重新执行命令,帮你实现 live-reload 的效果。
https://github.com/gravityblast/fresh
安装比较简单,执行 如下命令后,你就可以在任何地方执行 fresh
命令了。
go get github.com/pilu/fresh
Code language: JavaScript (javascript)
当你当前目录有 main.go
文件时,直接执行 fresh
就会自动执行 main.go
文件。不过,如果你想要自定义的话,也可以通过配置文件来完成。创建一个 sample.conf 文件,贴入如下配置,并执行 fresh -c sample.conf
就可以让 fresh 按照你的配置来执行命令。
root: .
tmp_path: ./tmp
build_name: runner-build
build_log: runner-build-errors.log
valid_ext: .go, .tpl, .tmpl, .html
no_rebuild_ext: .tpl, .tmpl, .html
ignored: assets, tmp
build_delay: 600
colors: 1
log_color_main: cyan
log_color_build: yellow
log_color_runner: green
log_color_watcher: magenta
log_color_app:<a href="https://github.com/gravityblast/fresh#usage"></a>
Code language: HTML, XML (xml)
写代码最常见的问题是容易太沉浸,一写就是好几个小时不挪窝。 最近发现了一个应用: Eye Monitor ,可以很好的解决沉浸式写代码导致的久坐问题。
番茄钟在很多场景下, 也都是实现打断的功效,强制你在一个特定的时间范围内做事 / 休息。但和大多数番茄钟不同的是,这个应用实现了一些有意思的活动算法,来帮你计算时间。而番茄钟则是明确的按时间卡住,不论这段时间你是否有操作,时间到了就是到了。
Eye Monitor 实现的效果就是当你的“疲劳度”达到了 100%,就会给你全屏幕弹窗,强制让你休息。虽然你也可以继续跳过,但跳过本身并不会降低你的疲劳度,只是让你可以短时间的休息一下,然后再次提醒你休息。这个设计是有点意思的。
比如,这是我的休息统计,可以看到疲劳值会增加,中间如果你有休息,疲劳值则会降低,再次开始 Coding 则会再次提交。除了可以用来提醒自己休息,还可以不定时的看看自己什么时候在 Happy Hacking,什么时候在摸鱼~
我在导入 Kindle 的图书时,错误的导入了两遍,导致我的 Calibre 仓库快速膨胀,因此,我希望清理掉其中重复的书籍,减少二次存储。
清理使用的是 Find Duplicates 这个插件
在 Calibre 的「首选项」-「插件安装」页面,搜索 Find Duplicates,就可以找到这个插件,双击安装,并重新启动,即可使用该插件。
如果你默认选择的是在主菜单展示工具,则会像我一样,在页面顶部有一个新的入口
使用非常简单,点击按钮,进入搜索页面,可以配置对比的元素,可以是二进制对比(更精准)或者是 基于某个特定的标签对比(比如 ISBN),也可以使用默认的标题作者对比。根据你的喜好,你可以选择合适的方式进行对比。
在下方的结果输出中,可以选择“Show All Groups at once with highlighting”,这样可以在一个列表中快速看到所有需要清理的图书,快速删除,达成目标。
点击确定,执行后的结果就是这样子的了,你只需要在重复的两本书中,选择你要删除的那一本即可。
在写爬虫的时候,我们会遇到最常见的问题是浏览器访问是一切正常的,但到代码编写的时候,就发现无法正常爬取。
这个时候往往是我们的爬虫所模拟的行为和代码里是不同的(比如浏览器拥有 30 个参数,但我们的代码中只有一个参数),从而导致最终执行的效果不同。而想要在代码中实现和浏览器相同的效果,最重要的是完全复制浏览器的行为,以便于让代码去模拟。
所以关键在于找到从30个参数正常运转,到1个参数不运转的关键参数,毕竟我们不想在代码当中添加太多的 Magic Value 来解决一些问题。所以要找到关键的参数。
首先,我们需要获取到和浏览器一样的数据,因为这个是我们进行后续的基础,有了它,我们才能进行从 30 个参数减少到 1 个参数。在执行这个步骤时,你需要借助 Chrome Devtools,来获取到现场。
使用 F12 打开 Chrome DevTools,切换到 「网络」Tab ,并刷新页面,以重新加载请求。页面正常加载完成后,你就可以从中找到你要 Debug 的请求。在这个请求上点击右键,选择「复制」,并选择「以 cURL 格式复制」,复制以后,你会得到如下的内容。这就是你在使用浏览器时,实际发送给服务器的请求。
你可以将这段命令放在 Terminal 中运行,你会看到和浏览器中的一样的内容输出。
此时,我们看复制出来的命令,其中包含了链接(我们的一个的参数),以及大量的 Header,这些 Header 中的某一个可能就是服务器将我们视为爬虫的 Header,然后拒绝我们的。
curl 'https://www.baidu.com/' \
-H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7' \
-H 'Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7' \
-H 'Cache-Control: no-cache' \
-H 'Connection: keep-alive' \
-H 'Cookie: BIDUPSID=1B455AFF07892965CF63335283C0BD80; PSTM=1690036933; BD_UPN=123253; BDUSS=BDcmp1YzFSeTRDLXVGZlNBbDJKZ08ya1lMQUpBVTlEaWM5WE9mV25YWn5EfmxrRVFBQUFBJCQAAAAAAAAAAAEAAADwPbowsNe084aq4MIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH-C0WR~gtFkan; BDUSS_BFESS=BDcmp1YzFSeTRDLXVGZlNBbDJKZ08ya1lMQUpBVTlEaWM5WE9mV25YWn5EfmxrRVFBQUFBJCQAAAAAAAAAAAEAAADwPbowsNe084aq4MIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH-C0WR~gtFkan; delPer=0; BD_CK_SAM=1; ZFY=sMxi79JqlMJjPMSJ3gQr5ht5g0MCtkIqefc2OTMspV4:C; BD_HOME=1; BDRCVFR[feWj1Vr5u3D]=I67x6TjHwwYf0; rsv_jmp_slow=1691762441727; BAIDUID=1B455AFF07892965CF63335283C0BD80:SL=0:NR=10:FG=1; sug=0; sugstore=1; ORIGIN=2; bdime=0; BAIDUID_BFESS=1B455AFF07892965CF63335283C0BD80:SL=0:NR=10:FG=1; PSINO=2; COOKIE_SESSION=161794_0_4_3_2_11_1_0_4_4_1_0_161839_0_9_0_1691922269_0_1691922260%7C4%230_0_1691922260%7C1; MCITY=-131%3A; H_PS_PSSID=36558_39217_38876_39118_39198_26350_39138_39100; BA_HECTOR=8gah01agag8g2kala0a12l2o1idr2ba1o; RT="z=1&dm=baidu.com&si=8e7a4596-4b9f-45c3-bf30-f7dffaddd79d&ss=llewny2r&sl=3&tt=1ag&bcn=https%3A%2F%2Ffclog.baidu.com%2Flog%2Fweirwood%3Ftype%3Dperf&ld=89j&ul=104t&hd=105p"' \
-H 'DNT: 1' \
-H 'Pragma: no-cache' \
-H 'Sec-Fetch-Dest: document' \
-H 'Sec-Fetch-Mode: navigate' \
-H 'Sec-Fetch-Site: none' \
-H 'Sec-Fetch-User: ?1' \
-H 'Upgrade-Insecure-Requests: 1' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36' \
-H 'sec-ch-ua: "Not.A/Brand";v="8", "Chromium";v="114", "Google Chrome";v="114"' \
-H 'sec-ch-ua-mobile: ?0' \
-H 'sec-ch-ua-platform: "macOS"' \
--compressed
Code language: JavaScript (javascript)
如果要查出哪些 Header 是关键参数,一个比较便捷的方式是采用二分法的方式来调试这些 Header。
具体操作时,你只需要删除一半的 Header,并再次发送 cURL,看是否可以正确获得我们想要的内容。如果你发现删除的这一半并不会让你拿到的返回结果报错,就可以继续使用二分法删除 Header,直到定位到真正会影响到我们正确获取结果的 Header 为止。
当你测试到某个 header 被移除后回导致无法正确返回内容,接下来你可以把剩下的 header 继续使用二分法来删除,测试具体会影响结果的 Header,直到删无可删之时,就说明我们已经拿到了能正常运转的最简参数。
此时,你就可以放心的将上述逻辑放在你的代码中来进行维护。
除此之外,你还可以使用上述的逻辑,把你的爬虫逻辑放进单元测试,这样当目标网站调整了爬虫逻辑之后,你就会快速发现。
以下内容仅为个人的记录,不做任何投资建议。
2022 年的时候,我曾经写过我的美股投资策略,如今已经 2023 ,我也经历了一轮买入和买出,并追加了投资,所以重新复盘一下过去一年的持仓变化。
2022 年时,我的持仓主要是 $AAPL、$MSFT、$NVDA 和 $FUTU,四只股票。总体投入的成本是 26,071.74港币,约等于 3,330.38 美元。
2023 年,我的持仓相比于去年有了一些变化。
目前的我同时在使用富途牛牛和长桥作为我的美股交易券商。之所以切换到长桥,主要的原因是其终身免佣金(股票交易里佣金的损耗还是挺夸张的)。
2023 年,我把去年的一些持仓全部都换成了新的持仓,可以趁机聊聊。
$AAPL 我是在 156.03 刀时买入,在 175 刀时卖出。持仓约 1 年,年化约 10%。写文章的此刻,$AAPL的价格是 174.310 刀。苹果的卖出是因为我希望将资金从富途迁移到长桥,所以做了卖出,而不是转券商(发现转券商是按持仓的股票来算的,卖出后的资金损耗反而更低一些)。当时主要是想着能够减少一些佣金,但实际上我的美股交易并不频繁,那么这个转仓是否真的有意义?
$FUTU 我是在 42.30 刀时买入,在 49.03 刀时卖出,持仓 1 年 2 个月,年化约 13.77%。写文章的此刻,$FUTU 的价格是 49.21 刀。富途的买入策略在上一篇里说过了,而卖出策略和 $AAPL 是一致的。不再赘述。
$NVDA 我是在 214 刀时买入,在 271.37 刀时卖出,持仓约 1年 2 个月,年化约 21.79%。写文章的此刻,$NVDA 的价格是435刀。NVDA 的买入策略在上一篇说过,是因为买不到 3090 ,就买了点 $NVDA。但卖出从现在来看,我认为自己还是太年轻,一个是其实当时完全没必要转仓,不交易其实完全可以接受。应该要忍住自己的精神洁癖。另外一个是对于 $NVDA 的成长性没感知,我卖掉的时候还是今年的 3 月, 彼时 ChatGPT 已经火了,对于我这个早期的 ChatGPT 用户来说,其实是能预测到利好 $NVDA 的。但是当时为了一个减少佣金的理由,就卖掉了。太年轻了!
$MSFT 我是在 295 刀时买入,在 318.83 刀时卖出,持仓约 1年 2 个月,年化收益约 6.69%。写文章的此刻,$MSFT 的价格是 319.780 刀。$MSFT 的卖出逻辑也是比较简单,主要是转仓,不多说。$MSFT在我卖之后,倒是没有涨特别多,所以我的心里负担没有 $NVDA 那么重。但回过头来看,依然不是一个很好的投资决策。
$API,已经记不得当时买入的价格了,不过目前的价格是 $2.96, 而我的成本是 $11.68,不过因为买入的不多,所以还好,一共也就 300 美元左右。目前的总价值大概是 $74 ,持有约 1 年时间,年化收益约 -74.66%。就目前而言,我可能会继续买入 $API,来拉低我的平均持仓成本。此外,这部分买入可能不会买入特别的多,按照目前的情况,我可能会再买入 100 股左右,把平均持仓成本拉到 $5 左右。
$API 这家公司我有接触过,总体来说,我认为在实时互动领域是掌握了核心科技和体验上的优越性的。所以我还是看好。当然,当初 100 多刀的价格也的确是疯狂。只是我没想到现在的日常价格是 $3 。
但另外一个层面,$3 也处在一个足够便宜的价格,对我来说,只要买入的总量不到某个占比特别大的值,我依然愿意购买。安全边际足够。
2023 年,我新增的股票是 $PDD
$PDD 买入价格 $72.804,2023.05.18 买入,持有 24 股,写文章的此刻价格为 $81.20,总价值 $1948.8。$PDD 的买入原因是我自己在日常使用 PDD 较多,且 PDD 做的是“便宜”,这个是永远的竞争力,我看好它(但可能是错的)。
$DIS 买入价格 $86.498,2023.08.07 买入,持有 33 股,写文章的此刻价格为 $86.340,总价值 $2849.38。$DIS 的买入逻辑则是源于迪士尼的造星运动,从过去的米奇,到后面的迪士尼影城,再到现在的玲娜贝儿,$DIS 的生命力似乎永远都不会有问题。考虑到西半球最强法务部,买了。
$BRK.B 买入价格 $329.760,2023.05.18,持有 1 股,写文章的此刻价格为 $355.70,总价值为 $355.79。$BRK.B 买的就是情怀了,毕竟入了价值投资的门,还是要买一股纪念一下的。万一明年能被抽中去听老爷子讲话呢。
2022 年到 2023 年,我整体来说是各种昏招频出。希望新的一年里,能够更加理智的进行投资,以及,多读点书,别tm瞎出招了。。。