导出到数据仓库
分析人员常常希望把 Sursa 数据和公司的其他数据放在一起:哪些卡片已核实、由谁核实,各团队的知识如何增长,哪些问题仍未得到回答。本指南构建一个定时作业,把 Sursa 的数据复制到您的数据仓库中,并保持副本为最新。
该作业组合使用 API 的三个部分:
| 部分 | 提供的内容 |
|---|---|
带 updated_since 的卡片列表,以及完整读取的短列表 | 上次运行以来变更的对象的当前状态。 |
| 事件记录 | 删除操作,以及 30 天内所发生事情的历史。 |
| 游标 | 不缺页、不重复的完整页面。 |
需要一个具有 cards:read 和 users:read 权限范围的密钥,以及用于读取事件记录的 webhooks:manage。为该作业单独创建一个具有 admin 角色的密钥:完整的用户列表和事件记录只对这种密钥开放。角色和数据权限档案更窄的密钥只导出某个群体可以看到的内容;它读取的是简要的用户列表(brief=true),也不能读取事件记录,因此要从下文所述的每周全量加载中获取删除操作。
表
一种简单的布局:每种对象一张表,事件一张表:
| 表 | 键 | 数据来源 |
|---|---|---|
sursa_cards | id | GET /v1/cards |
sursa_collections | id | GET /v1/collections |
sursa_users | id | GET /v1/users |
sursa_groups | id | GET /v1/groups |
sursa_events | id | GET /v1/webhook-events |
把每个对象按原样存入一个 JSON 列,再加上经常查询的列(updated_at、collection_id、status、owner_id)。API 的后续版本会新增字段;有了 JSON 列,无需修改作业就能获得这些字段。
首次加载
不带 updated_since 完整读取每个列表,写入所有对象。记住加载开始的时刻:下一次运行从这里继续。
增量加载
每次运行时(例如每 15 分钟一次):
- 记住本次运行的开始时间。
- 读取卡片时,把
updated_since设为上次运行的开始时间减去 1 分钟余量,并用游标和相同的updated_since请求后续每一页(不带第一页筛选条件的游标返回400 api.cursor_invalid)。合集、用户和用户组完整读取:合集和用户没有updated_at,用户组的成员变化时,其updated_at也不会变。按id写入或替换每个对象。 - 从上次保存的游标开始读取事件记录。把每个事件追加到
sursa_events,并应用删除操作:card.deleted事件把卡片从sursa_cards中删除;如果保留历史,则把它标记为已删除。 - 保存开始时间和新的事件记录游标;如果数据仓库支持,与数据放在同一个事务中保存。
import json
from datetime import datetime, timedelta, timezone
LISTS = {"cards": "/v1/cards", "collections": "/v1/collections", "users": "/v1/users", "groups": "/v1/groups"}
def read_list(path, since):
"""Every object of a list, changed since `since` (or all of them). The next page keeps the filters of the first."""
params = {"limit": 200}
if since:
params["updated_since"] = (since - timedelta(minutes=1)).isoformat()
while params:
response = session.get(BASE + path, params=params, timeout=120)
response.raise_for_status()
body = response.json()
items = body["data"] if isinstance(body, dict) and "data" in body else body
yield from items
if isinstance(body, dict) and "data" in body:
params = {**params, "cursor": body["next_cursor"]} if body.get("has_more") else None
else:
cursor = response.headers.get("X-Next-Cursor")
params = {**params, "cursor": cursor} if cursor else None
def read_events(cursor):
"""New events of the feed after `cursor` (or from the start of the 30 days), and the cursor to save."""
events = []
params = {"limit": 200, **({"cursor": cursor} if cursor else {})}
while True:
page = api("GET", "/v1/webhook-events?" + requests.compat.urlencode(params))
events += page["data"]
params["cursor"] = page["next_cursor"]
if not page["has_more"]:
return events, page["next_cursor"]
def run(state, warehouse):
started_at = datetime.now(timezone.utc)
since = state.get("since")
for table, path in LISTS.items():
for obj in read_list(path, since if table == "cards" else None):
warehouse.upsert(f"sursa_{table}", obj["id"], json.dumps(obj))
events, feed_cursor = read_events(state.get("feed_cursor"))
for event in events:
warehouse.insert_once("sursa_events", event["id"], json.dumps(event))
if event["type"] == "card.deleted":
warehouse.delete("sursa_cards", event["object"]["id"])
warehouse.save_state({"since": started_at, "feed_cursor": feed_cursor})事件记录的游标在每一页都会返回,包括最后一页。保存它,下一次运行就会紧接在已保存的最后一个事件之后开始,因此不会重复读取或跳过任何事件。
让副本保持准确
- 超过 30 天没有运行。事件记录保留事件 30 天。如果作业超过 30 天没有运行,请重新执行一次全量加载,并从表中删除全量加载没有返回的所有对象。
- 每周一次全量加载。即使事件记录一切正常,每周执行一次全量加载并与表进行比较,也是检查副本是否完整的低成本方法。
- 密钥看不到的内容不会被导出。作业看到的内容与其密钥完全一致。如果某张卡片超出了密钥的访问范围(例如被移到了受限的合集),全量加载不会返回它;请按已删除处理。
- 问题。问题日志有单独的导出:
GET /v1/questions/export.csv把一段时间内的问题作为一个 CSV 文件返回,包含评分和来源。它需要ask权限范围。
遵守限制
大型组织的全量加载会发出很多请求。请一次读取一个列表,使用 limit=200,并留意 RateLimit 响应头。每 15 分钟运行一次、只读取变更的作业,远低于任何配额。