Matrix 首页推荐
Matrix 是少数派的写作社区,我们主张分享真实的产品体验,有实用价值的经验与思考。我们会不定期挑选 Matrix 最优质的文章,展示来自用户的最真实的体验和观点。
文章代表作者个人观点,少数派仅对标题和排版略作修改。
一句话介绍:飞书多维表格 + 扫码枪读取纸质书 ISBN 号 + 爬取豆瓣数据 + 飞书应用开发能力 = 快速构建个人家庭图书管理系统。
先看一下最后的成果吧。虽然我还没完全把书全录进去,但模板和代码已经搞定了。大家可以直接使用我的模板。
接下来详细记录一下我创建模板和录入数据的过程。
契机是这样的,最近家里新买了一个书柜,我想着反正要把书都倒腾出来摆放一次,不妨顺便把家里的纸质书登记为一个表格;而且我最近翻出了一个以前买的扫码枪,可以发挥一下作用(实践下来发现确实挺好玩的)。
在工具的选择上,我没犹豫太多就选了飞书的多维表格。多维表格的扩展和开发能力很强,也能够快速把一个基础的数据表呈现为多种看板、画廊效果。而且多维表格最近还新增了 AI 捷径,可以直接调用 AI 能力填充表格。
整体设计思路大概分为以下几部分:
在飞书云文档新建多维表格。如果不想逐个手动配置字段的话,可以点击右上角的 AI 助手飞飞,告诉它希望增加哪些字段,AI 就会帮助我们生成表头并设置出最为合适的字段属性:
我这次的思路是:先通过 ISBN 获取豆瓣图书数据,然后使用飞书的 AI 能力补充生成需要的字段,最后再手动录入少量数据。基于此,要录入的信息分为三类:
通过自动抓取导入 + AI 补全生成,尽可能减少手工录入的工作量。同时在多维表格中,数据表是最基础的数据库,后续可以基于数据表格创建不同的视图、表单或仪表盘,不过现在还是空表,所以我们把其他视图放到最后再介绍。
如果可以的话,用 API 是最好的,不过豆瓣的接口很早就关闭了,我在网上搜了一下,也没有其他太好用的图书 API(或者都不免费)。鉴于个人使用一共也没多少本书,所以可以还是做一下豆瓣的爬虫吧,仅供学习使用,不会对豆瓣服务器造成太大压力。
使用豆瓣的搜索接口提交 ISBN 号1,从第一条搜索结果中获取图书链接并爬取更详细的信息。
如图,豆瓣的搜索接口是:https://book.douban.com/j/subject_suggest?q=
params = {'q': isbn}
response_suggest = requests.get('https://book.douban.com/j/subject_suggest', params=params, headers=headers_suggest)
if response_suggest.status_code != 200 or not response_suggest.json():
return "未找到相关书籍信息"
book_suggest = response_suggest.json()[0]
book_url = book_suggest['url'].replace('\\', '')
获取图书详情页https://book.douban.com/subject/35335514/
后,请求该页面数据,并从页面中提取相应信息:
不过由于豆瓣的图书简介内容并没有统一数据格式,这里还需要做很多错误处理,举个例子,比如定价这里,有时会写 59.00 元,有时会写¥59 或 CNY 59,有时会写 59,有时信息缺失干脆没有这个字段,所以为了避免程序处理时报错,就需要做一些处理,比如使用正则:
try:
price = float(re.search(r"(?:CNY\s*)?(\d+(?:\.\d+)?)(?:\s*元)?", soup.find('span', text='定价:').next_sibling.strip().replace('元', '')).group(1))
except AttributeError:
price = ''
其他字段的处理类似,这里就不再赘述了。
还有一个稍微特殊一点的就是封面图片,多维表格中支持上传图片附件,可以将封面图生成画廊,所以我们也需要顺便记录一下封面图片的 URL 地址,后续可以下载图片并上传到飞书中。
豆瓣的信息里,其实没有图书的分类信息,我本来想参考中图法2去做图书分类,后来发现太复杂了,家里这么点书确实没必要那么精细的分类。
然后我试了一下飞书的 AI 功能,其实可以用 AI 自动补充图书分类:
再比如,豆瓣简介写的比较长,我们可以让 AI 进行总结,生成一句话简介:
这样设置好了之后,只要我们填入前几列的字段,飞书 AI 就会帮助我们自动补全这两列数据,非常好用。
多维表格其实自带一个 Webhook 接口用于提交数据,具体做法是点击文档右上角的「自动化」:
「创建自定义流程」:
左边选择「接收到 Webhook 时」,会得到一个 Webhook 地址,向这个地址发送 JSON 格式的数据,服务器就会收到并自动解析为字段;然后右侧选择「新增记录」,选择插入数据的位置为数据表,然后设置记录内容即可:
不过……我感觉这样做还是不是太方便,因为需要手动逐个去设置字段匹配,字段一多或者需要修改时也比较麻烦。所以我还是用开发应用的方式来实现,登录飞书开放平台,选择创建企业自建应用:
填写基本信息:
创建完成后有很多可以设置的选项,不过我们这次主要会用到「应用凭证」、「权限管理」和「版本发布」这几项功能,其余的大家可以自行阅读飞书的开发文档(在这里要保存好 App ID
和 App Secret
供后面使用)。
想要通过应用向多维表格中提交数据,主要需要几个过程,我会把文档附在这里供大家参考:
这个过程中,还需要向应用开放相应的操作权限,我建议是在「开发文档」中调试接口时,开通相应的权限。
具体来说,我们需要提前准备好 4 个参数,分别是:
appid
:飞书应用的唯一标识,在应用设置页面获取app_secret
:在应用设置页面获取,和 appid
一起使用,用于获取tenant_access_token
table_apptoken
:多维表格 App 的唯一标识,从多维表格链接中获取。table_id
:多维表格数据表的唯一标识,从多维表格链接中获取。提交appid
和 app_secret
,获得tenant_access_token
。
def access_token():
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
payload = json.dumps({
"app_id": feishu_appid,
"app_secret": feishu_app_secret
})
headers = {
'Content-Type': 'application/json'
}
response = requests.request("POST", url, headers=headers, data=payload)
return response.json()['tenant_access_token']
对于附件类内容,需要先完成上传获取file_token
,后续提交表格记录时需要用到。这一步直接用飞书文档里的示例代码就行:
import os
import requests
from requests_toolbelt import MultipartEncoder
def upload_media(file_path, access_token):
file_size = os.path.getsize(file_path)
url = "https://open.feishu.cn/open-apis/drive/v1/medias/upload_all"
form = {
'file_name': os.path.basename(file_path),
'parent_type': 'bitable_image',
'parent_node': table_apptoken,
'size': str(file_size),
'file': (open(file_path, 'rb'))
}
print(form)
multi_form = MultipartEncoder(form)
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': multi_form.content_type
}
response = requests.request("POST", url, headers=headers, data=multi_form)
print(response.json())
if response.status_code == 200:
return response.json().get('data', {}).get('file_token')
else:
return None
这一步主要是结合数据表的结构,构造好需要提交的 JSON。主要是需要结合文档,注意不同类型的字段必须匹配相应的格式。比如,在表格中设置「豆瓣链接」的类型为超链接,这里就必须提供text
和link
;「出版时间」设置为日期,就需要提交时间戳而非字符串;「封面图」设置为附件,就需要提交file_token
;还有一些数字格式,也要匹配具体的设置。
如果提交的数据和设置的字段类型不匹配就会报错,所以需要仔细 debug 一下。代码比较简单,大家应该能看明白:
def insert_book_info_to_feishu(book_info, file_token, access_token):
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{table_apptoken}/tables/{table_id}/records"
payload = json.dumps({
"fields": {
"书名": book_info["书名"],
"作者": book_info["作者"],
"出版社": book_info["出版社"],
"出版时间": book_info["出版时间"],
"页数": book_info["页数"],
"定价": book_info["定价"],
"ISBN": book_info["ISBN"],
"豆瓣评分": book_info["豆瓣评分"],
"评分人数": book_info["评分人数"],
"豆瓣链接": {
"text": book_info["书名"],
"link": book_info["豆瓣链接"]
},
"豆瓣简介": book_info["豆瓣简介"],
"封面图": [
{
"file_token": file_token
}
]
}
})
payload = construct_payload(book_info, file_token)
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {access_token}'
}
response = requests.request("POST", url, headers=headers, data=payload)
print(response.json())
print(f'飞书接口状态:{response.status_code}')
return response.status_code
哦对顺便说一句,如果是专业搞飞书应用开发的,可以直接用 Python SDK,我手搓代码是这次因为一共也就两三个接口的事。
完成以上准备工作之后,我们就可以开始录入数据了。这时就要祭出「扫码枪」了。具体来说,我用到的这种扫码枪叫做「红光一维码扫码枪」3:
大家现在用的更多的是手机扫描二维码。其实扫码枪这玩意儿一点都不神秘,超市收银台标配。而且一维码条形发明都几十年了,被广泛应用于商品标识。
扫码枪的原理其实很简单,就是通过光学扫描系统读取条形码中的数据,将其转换为电信号,再经过处理器处理后,模拟键盘输入的形式将数据传递给计算机等设备。
不过我用的并不是那种「有线枪式」的扫码枪,而是一个便携的蓝牙扫描枪(不带货哈)。所以对于电脑来说,扫码枪类似于一个无线键盘:
对于用户来说,只需要知道,连接扫码枪之后,你把键入的光标放在哪里,它就会把扫码结果直接输入到哪里(我录了个短视频,不知道能不能看清楚)。
扫码枪也有许多设置项,其中一项便是扫码结尾的字符,如果不做设置,扫码枪在连续扫描时就会录入一长串字符,所以一般来说会在结尾设置\n
换行符。
到此我们就可以把以上所有工作流程串在一起了,运行起来的效果大概是这样:
比较费时间的其实是下载和上传图片。大家注意看最后几秒,在自动上传一条新纪录后,多维表格的快捷 AI 会根据我们的设置,自动填充好「一句话总结」和「AI 分类」两个字段。
这样基本上就能 10s 内录完一本书的数据,而且全程只需要做一个动作,就是扫码,很快就能整理出家里的图书数据库。
有了基础的数据表之后,就可以根据自己的需要创建更多视图。所谓视图,就是展现数据的不同方式。大家可以根据自己的数据类型和情况探索一下展示效果,这里不再详细介绍了~
而且你别说,做完这个图书数据表之后,我觉得其他家庭数据库也挺有搞头的,用类似的思路,可以很快搭建出「家庭药箱」「家庭食品保质期」「老婆的化妆品」等等数据表,无非就是替换一下条形码查询数据的接口,而且类似这种需要注意保质期的数据表,还能利用多维表格中的到期提醒功能自动发送提醒。下周有空就做。
最后我把全部代码分享一下,由于是自己用的,所以并没有刻意做什么整理,大家随便看看,仅供参考。
以上。
附录(本文用到的全部代码):
import requests
from bs4 import BeautifulSoup
import json
import os
from requests_toolbelt import MultipartEncoder
import time
from datetime import datetime
import re
#设置飞书应用id
feishu_appid = ''
feishu_app_secret = ''
#设置飞书表格的id
table_apptoken = ''
table_id = ''
def access_token():
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
payload = json.dumps({
"app_id": feishu_appid,
"app_secret": feishu_app_secret
})
headers = {
'Content-Type': 'application/json'
}
response = requests.request("POST", url, headers=headers, data=payload)
# print(response.json())
return response.json()['tenant_access_token']
def upload_media(file_path, access_token):
file_size = os.path.getsize(file_path)
url = "https://open.feishu.cn/open-apis/drive/v1/medias/upload_all"
form = {
'file_name': os.path.basename(file_path),
'parent_type': 'bitable_image',
'parent_node': table_apptoken,
'size': str(file_size),
'file': (open(file_path, 'rb'))
}
print(form)
multi_form = MultipartEncoder(form)
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': multi_form.content_type
}
response = requests.request("POST", url, headers=headers, data=multi_form)
print(response.json())
if response.status_code == 200:
return response.json().get('data', {}).get('file_token')
else:
return None
def get_book_info_by_isbn(isbn):
# 请求书籍搜索建议接口,获取书籍的URL和封面图片链接
# 如请求失败,修改headers或增加cookies
headers_suggest = {
'sec-ch-ua': '"Microsoft Edge";v="129", "Not=A?Brand";v="8", "Chromium";v="129"',
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0',
'x-requested-with': 'XMLHttpRequest',
}
params = {'q': isbn}
response_suggest = requests.get('https://book.douban.com/j/subject_suggest', params=params, headers=headers_suggest)
if response_suggest.status_code != 200 or not response_suggest.json():
return "未找到相关书籍信息"
book_suggest = response_suggest.json()[0]
book_url = book_suggest['url'].replace('\\', '')
pic_url = book_suggest['pic'].replace('\\', '').replace('subject/s','subject/l')
# 请求书籍详情页,提取书籍的详细信息
# 如请求失败,修改headers或增加cookies
headers_detail = {
'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',
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0',
}
response_detail = requests.get(book_url, headers=headers_detail)
soup = BeautifulSoup(response_detail.text, 'html.parser')
# 提取信息
try:
author = soup.find('span', text=' 作者').find_next_sibling('a').text.strip()
except AttributeError:
author = ''
try:
publisher = soup.find('span', text='出版社:').find_next_sibling('a').text.strip()
except AttributeError:
publisher = ''
try:
pub_year = soup.find('span', text='出版年:').next_sibling.strip()
pub_year = convert_to_timestamp(pub_year)
except AttributeError:
pub_year = ''
try:
pages = int(soup.find('span', text='页数:').next_sibling.strip())
except AttributeError:
pages = ''
try:
price = float(re.search(r"(?:CNY\s*)?(\d+(?:\.\d+)?)(?:\s*元)?", soup.find('span', text='定价:').next_sibling.strip().replace('元', '')).group(1))
except AttributeError:
price = ''
try:
rating = float(soup.find('strong', class_='rating_num').text.strip())
except:
rating = ''
try:
votes = int(soup.find('span', property='v:votes').text.strip())
except AttributeError:
votes = ''
try:
intro_div = soup.find('div', class_='intro')
paragraphs = intro_div.find_all('p')
intro = "\n".join([p.get_text() for p in paragraphs])
except AttributeError:
intro = ''
# 整合提取的书籍信息
book_info = {
"书名": book_suggest['title'],
"作者": author,
"出版社": publisher,
"出版时间": pub_year,
"页数": pages,
"定价": price,
"ISBN": isbn,
"豆瓣评分": rating,
"评分人数": votes,
"豆瓣链接": book_url,
"封面图链接": pic_url,
"豆瓣简介": intro
}
return book_info
def download_cover_image(cover_url, save_path):
response = requests.get(cover_url)
if response.status_code == 200:
with open(save_path, 'wb') as file:
file.write(response.content)
return save_path
else:
return None
def convert_to_timestamp(date_str):
# 使用正则表达式匹配不同的日期格式
date_formats = [
r'^(\d{4})-(\d{1,2})-(\d{1,2})
$', # 2023-02-01
r'^(\d{4})-(\d{1,2})$
', # 2023-02 或 2023-2
r'^(\d{4})$', # 仅有年份 2023
]
for date_format in date_formats:
match = re.match(date_format, date_str)
if match:
groups = match.groups()
if len(groups) == 3: # 解析到年-月-日
year, month, day = int(groups[0]), int(groups[1]), int(groups[2])
elif len(groups) == 2: # 解析到年-月
year, month, day = int(groups[0]), int(groups[1]), 1 # 默认设置为1日
elif len(groups) == 1: # 仅解析到年份
year, month, day = int(groups[0]), 1, 1 # 默认设置为1月1日
# 构造日期对象并转化为时间戳
date_obj = datetime(year, month, day)
timestamp = int(date_obj.timestamp() * 1000) # 转为毫秒级时间戳
return timestamp
raise ValueError("无法解析日期格式")
def construct_payload(book_info, file_token):
# 初始化空的字段字典
fields = {}
# 动态构造需要提交的字段,跳过空值的字段
if book_info.get("书名"):
fields["书名"] = book_info["书名"]
if book_info.get("作者"):
fields["作者"] = book_info["作者"]
if book_info.get("出版社"):
fields["出版社"] = book_info["出版社"]
if book_info.get("出版时间"):
fields["出版时间"] = book_info["出版时间"]
if book_info.get("页数"):
fields["页数"] = book_info["页数"]
if book_info.get("定价"):
fields["定价"] = book_info["定价"]
if book_info.get("ISBN"):
fields["ISBN"] = book_info["ISBN"]
if book_info.get("豆瓣评分"):
fields["豆瓣评分"] = book_info["豆瓣评分"]
if book_info.get("评分人数"):
fields["评分人数"] = book_info["评分人数"]
if book_info.get("豆瓣链接") and book_info.get("书名"):
fields["豆瓣链接"] = {
"text": book_info["书名"],
"link": book_info["豆瓣链接"]
}
if book_info.get("豆瓣简介"):
fields["豆瓣简介"] = book_info["豆瓣简介"]
if file_token:
fields["封面图"] = [{"file_token": file_token}]
# 构造最终的 payload
payload = json.dumps({"fields": fields})
return payload
def insert_book_info_to_feishu(book_info, file_token, access_token):
url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{table_apptoken}/tables/{table_id}/records"
payload = json.dumps({
"fields": {
"书名": book_info["书名"],
"作者": book_info["作者"],
"出版社": book_info["出版社"],
"出版时间": book_info["出版时间"],
"页数": book_info["页数"],
"定价": book_info["定价"],
"ISBN": book_info["ISBN"],
"豆瓣评分": book_info["豆瓣评分"],
"评分人数": book_info["评分人数"],
"豆瓣链接": {
"text": book_info["书名"],
"link": book_info["豆瓣链接"]
},
"豆瓣简介": book_info["豆瓣简介"],
"封面图": [
{
"file_token": file_token
}
]
}
})
payload = construct_payload(book_info, file_token)
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {access_token}'
}
response = requests.request("POST", url, headers=headers, data=payload)
print(response.json())
print(f'飞书接口状态:{response.status_code}')
return response.status_code
def upload_book_to_feishu(isbn):
# 获取飞书的 access_token
token = access_token()
# 获取书籍信息
book_info = get_book_info_by_isbn(isbn)
print(book_info)
# 下载封面图片
cover_url = book_info['封面图链接']
save_path = f"./{isbn}.jpg"
cover_file_path = download_cover_image(cover_url, save_path)
if cover_file_path:
# 上传封面图片至飞书并获取file_token
file_token = upload_media(cover_file_path, token)
if file_token:
# 插入书籍信息到飞书表格
status_code = insert_book_info_to_feishu(book_info, file_token, token)
return status_code
else:
print("封面图片上传失败")
else:
print("封面图片下载失败")
#使用示例
def scan_isbn_and_upload():
print("请使用扫码枪输入ISBN,按 Ctrl+C 结束程序。")
while True:
try:
# 等待用户通过扫码枪输入ISBN
isbn = input("扫描ISBN: ").strip() # .strip() 用于去除前后多余的空白和换行符
if isbn:
print(f"正在处理ISBN: {isbn}")
upload_book_to_feishu(isbn) # 调用函数上传书籍信息
print(f"ISBN: {isbn} 处理完成\n")
else:
print("未检测到有效的ISBN,请重试。")
except KeyboardInterrupt:
# 用户按下 Ctrl+C 后,退出循环
print("\n程序已终止。")
break
#调用函数,开始监听扫码输入
scan_isbn_and_upload()
题图来自 Hermann Kollinger, Pixabay
> 关注 少数派小红书,感受精彩数字生活 🍃
> 实用、好用的 正版软件,少数派为你呈现 🚀