Add Google Sheets sync script
This commit is contained in:
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
# Optional local environment values. Never commit a populated .env file.
|
||||||
|
GITEA_BASE_URL=https://gitea.example.invalid
|
||||||
|
GITEA_OWNER=replace-with-gitea-owner
|
||||||
|
GIT_CREDENTIAL_SECRET=
|
||||||
|
SYNC_FAILURE_WEBHOOK_URL=
|
||||||
Executable
+39
@@ -0,0 +1,39 @@
|
|||||||
|
name: Sync public Google Sheet
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# 03:00 Asia/Shanghai is 19:00 UTC on the previous day.
|
||||||
|
- cron: "0 19 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
SOURCE_WORKBOOK_ID: 1DPbdVsX3SG_kgDXs2j34mDAgplRSfcAWKKG9yqalFmw
|
||||||
|
TARGET_REPOSITORY: ttshop
|
||||||
|
TARGET_BRANCH: main
|
||||||
|
PUBLISH_PATH: data/merged.html
|
||||||
|
GITEA_BASE_URL: ${{ vars.GITEA_BASE_URL }}
|
||||||
|
GITEA_OWNER: ${{ vars.GITEA_OWNER }}
|
||||||
|
GIT_CREDENTIAL_SECRET: ${{ secrets.GIT_CREDENTIAL_SECRET }}
|
||||||
|
SYNC_FAILURE_WEBHOOK_URL: ${{ secrets.SYNC_FAILURE_WEBHOOK_URL }}
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Install requests
|
||||||
|
run: python -m pip install --disable-pip-version-check -r requirements.txt
|
||||||
|
|
||||||
|
- name: Synchronize workbook
|
||||||
|
run: python sync.py
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
# Local environment and secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Python caches
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# TTSHOP 同步脚本
|
||||||
|
|
||||||
|
`sync.py` 使用 Python 标准库和 `requests` 读取公开 Google Sheets 工作簿,并把所有工作表合并成一个 UTF-8 HTML 表格,固定写入 `data/merged.html`。工作表名称和顺序来自 XLSX 导出,单元格使用公开 gviz 接口返回的显示值。
|
||||||
|
|
||||||
|
合并规则:每张表第一行是标题;列按首次出现顺序取并集;源表已有的 `LinkName` 会改名为 `LinkName_Source`、`LinkName_Source_2` 等并保留;脚本最后追加生成的 `LinkName` 列。HTML 文本会进行转义。内容没有变化时不产生 Git 提交,变化时只提交 `data/merged.html` 并推送到 `main`。
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m pip install -r requirements.txt
|
||||||
|
python sync.py
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本默认使用固定工作簿 ID `1DPbdVsX3SG_kgDXs2j34mDAgplRSfcAWKKG9yqalFmw`、仓库 `ttshop`、分支 `main` 和输出路径 `data/merged.html`。本地运行需要当前目录是该仓库,并且 Git 已配置可推送的 `origin`;不要把凭据写入 remote URL。
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
- `GIT_CREDENTIAL_SECRET`:可选的 Git 密码/token。脚本通过临时 `GIT_ASKPASS` 使用,不写入 URL、日志或提交文件。
|
||||||
|
- `GITEA_OWNER`:Git askpass 的用户名提示值;workflow 从仓库变量注入。
|
||||||
|
- `GITEA_BASE_URL`:Gitea 地址,作为 workflow 配置保留;实际推送使用 checkout 提供的 `origin`。
|
||||||
|
- `SYNC_FAILURE_WEBHOOK_URL`:可选的失败通知 URL。失败时最多发送一次简短通知,通知失败不会覆盖原始异常。
|
||||||
|
|
||||||
|
## Gitea Actions
|
||||||
|
|
||||||
|
`.gitea/workflows/sync.yml` 每天执行一次 `0 19 * * *`(按 UTC 解释时为 Asia/Shanghai 次日 03:00),也支持手动触发。workflow 只安装 `requirements.txt` 中的 `requests` 并运行 `python sync.py`,不依赖包 CLI、测试 extras、runner、scheduler 或报告系统。
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
requests==2.32.3
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Sync a public Google Sheets workbook into data/merged.html."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
WORKBOOK_ID = "1DPbdVsX3SG_kgDXs2j34mDAgplRSfcAWKKG9yqalFmw"
|
||||||
|
PUBLISH_PATH = Path("data/merged.html")
|
||||||
|
GVIZ_PREFIX = "google.visualization.Query.setResponse("
|
||||||
|
|
||||||
|
|
||||||
|
def sheet_names(xlsx: bytes) -> list[str]:
|
||||||
|
"""Return worksheet names in the order stored by the workbook."""
|
||||||
|
with zipfile.ZipFile(BytesIO(xlsx)) as package:
|
||||||
|
root = ET.fromstring(package.read("xl/workbook.xml"))
|
||||||
|
names = [
|
||||||
|
node.attrib["name"]
|
||||||
|
for node in root.iter()
|
||||||
|
if node.tag.rsplit("}", 1)[-1] == "sheet" and node.attrib.get("name")
|
||||||
|
]
|
||||||
|
if not names:
|
||||||
|
raise ValueError("workbook contains no worksheets")
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def read_gviz(sheet: str) -> list[list[str]]:
|
||||||
|
params = urlencode({"sheet": sheet, "headers": "0"})
|
||||||
|
url = f"https://docs.google.com/spreadsheets/d/{WORKBOOK_ID}/gviz/tq?{params}"
|
||||||
|
response = requests.get(url, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
text = response.text.strip()
|
||||||
|
if text.endswith(";"):
|
||||||
|
text = text[:-1].rstrip()
|
||||||
|
if not text.startswith(GVIZ_PREFIX) or not text.endswith(")"):
|
||||||
|
raise ValueError(f"invalid gviz response for worksheet {sheet!r}")
|
||||||
|
payload = json.loads(text[len(GVIZ_PREFIX) : -1])
|
||||||
|
if payload.get("status") != "ok":
|
||||||
|
raise ValueError(f"gviz query failed for worksheet {sheet!r}")
|
||||||
|
table = payload.get("table", {})
|
||||||
|
columns = table.get("cols", [])
|
||||||
|
rows = table.get("rows", [])
|
||||||
|
width = len(columns)
|
||||||
|
|
||||||
|
def cell_value(cell: dict | None) -> str:
|
||||||
|
if not cell:
|
||||||
|
return ""
|
||||||
|
value = cell.get("f", cell.get("v", ""))
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "TRUE" if value else "FALSE"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
result: list[list[str]] = []
|
||||||
|
for row in rows:
|
||||||
|
cells = row.get("c", [])
|
||||||
|
if len(cells) > width:
|
||||||
|
raise ValueError(f"gviz row is wider than columns in worksheet {sheet!r}")
|
||||||
|
values = [cell_value(cell) for cell in cells]
|
||||||
|
result.append(values + [""] * (width - len(values)))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_sheet(name: str, rows: list[list[str]]) -> tuple[list[str], list[list[str]]]:
|
||||||
|
if not rows:
|
||||||
|
return [], []
|
||||||
|
headers = rows[0]
|
||||||
|
if any(header == "" for header in headers):
|
||||||
|
raise ValueError(f"worksheet {name!r} has an empty header")
|
||||||
|
if len(set(headers)) != len(headers):
|
||||||
|
raise ValueError(f"worksheet {name!r} has duplicate headers")
|
||||||
|
|
||||||
|
final_headers = headers[:]
|
||||||
|
if "LinkName" in final_headers:
|
||||||
|
index = final_headers.index("LinkName")
|
||||||
|
suffix = 1
|
||||||
|
candidate = "LinkName_Source"
|
||||||
|
while candidate in final_headers:
|
||||||
|
suffix += 1
|
||||||
|
candidate = f"LinkName_Source_{suffix}"
|
||||||
|
final_headers[index] = candidate
|
||||||
|
|
||||||
|
data = [row + [""] * (len(headers) - len(row)) for row in rows[1:]]
|
||||||
|
return final_headers, data
|
||||||
|
|
||||||
|
|
||||||
|
def merge_sheets(sheets: list[tuple[str, list[str], list[list[str]]]]) -> tuple[list[str], list[list[str]]]:
|
||||||
|
"""Union columns by first appearance and append each source row once."""
|
||||||
|
columns: list[str] = []
|
||||||
|
for _, headers, _ in sheets:
|
||||||
|
for header in headers:
|
||||||
|
if header not in columns:
|
||||||
|
columns.append(header)
|
||||||
|
if not columns:
|
||||||
|
return ["LinkName"], []
|
||||||
|
|
||||||
|
output = [columns + ["LinkName"]]
|
||||||
|
for sheet_name, headers, rows in sheets:
|
||||||
|
positions = {header: index for index, header in enumerate(headers)}
|
||||||
|
for row in rows:
|
||||||
|
output.append([
|
||||||
|
row[positions[column]] if column in positions else ""
|
||||||
|
for column in columns
|
||||||
|
] + [sheet_name])
|
||||||
|
return output[0], output[1:]
|
||||||
|
|
||||||
|
|
||||||
|
def render_html(headers: list[str], rows: list[list[str]]) -> bytes:
|
||||||
|
def cells(tag: str, values: list[str]) -> str:
|
||||||
|
return "<tr>" + "".join(
|
||||||
|
f"<{tag}>{html.escape(value, quote=True)}</{tag}>" for value in values
|
||||||
|
) + "</tr>"
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"<!doctype html>",
|
||||||
|
'<html><head><meta charset="utf-8"><title>TTSHOP</title></head><body>',
|
||||||
|
"<table>",
|
||||||
|
cells("th", headers),
|
||||||
|
*[cells("td", row) for row in rows],
|
||||||
|
"</table>",
|
||||||
|
"</body></html>",
|
||||||
|
]
|
||||||
|
return ("\n".join(lines) + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def run_git(args: list[str], env: dict[str, str]) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", *args], cwd=ROOT, env=env, check=True, text=True,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def publish(content: bytes) -> None:
|
||||||
|
target = ROOT / PUBLISH_PATH
|
||||||
|
if target.exists() and target.read_bytes() == content:
|
||||||
|
print("data/merged.html is unchanged")
|
||||||
|
return
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_bytes(content)
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["GIT_TERMINAL_PROMPT"] = "0"
|
||||||
|
askpass_path: str | None = None
|
||||||
|
credential = env.get("GIT_CREDENTIAL_SECRET", "")
|
||||||
|
if credential:
|
||||||
|
with tempfile.NamedTemporaryFile("w", prefix="ttshop-askpass-", delete=False) as file:
|
||||||
|
file.write(
|
||||||
|
"#!/bin/sh\n"
|
||||||
|
'case "$1" in\n'
|
||||||
|
'*Username*) printf "%s\\n" "${GITEA_OWNER:-oauth2}" ;;\n'
|
||||||
|
'*Password*) printf "%s\\n" "$GIT_CREDENTIAL_SECRET" ;;\n'
|
||||||
|
'esac\n'
|
||||||
|
)
|
||||||
|
askpass_path = file.name
|
||||||
|
os.chmod(askpass_path, 0o700)
|
||||||
|
env["GIT_ASKPASS"] = askpass_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
run_git(["add", "--", str(PUBLISH_PATH)], env)
|
||||||
|
staged = subprocess.run(
|
||||||
|
["git", "diff", "--cached", "--quiet", "--", str(PUBLISH_PATH)],
|
||||||
|
cwd=ROOT, env=env, check=False,
|
||||||
|
)
|
||||||
|
if staged.returncode not in (0, 1):
|
||||||
|
raise RuntimeError("could not inspect staged output")
|
||||||
|
if staged.returncode == 0:
|
||||||
|
print("data/merged.html is unchanged")
|
||||||
|
return
|
||||||
|
run_git([
|
||||||
|
"-c", "user.name=TTSHOP Sync", "-c", "user.email=ttshop-sync@localhost",
|
||||||
|
"commit", "--no-gpg-sign", "-m", "Update merged Google Sheet",
|
||||||
|
], env)
|
||||||
|
run_git(["push", "origin", "HEAD:main"], env)
|
||||||
|
print("published data/merged.html")
|
||||||
|
finally:
|
||||||
|
if askpass_path:
|
||||||
|
Path(askpass_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def sync() -> None:
|
||||||
|
export_url = f"https://docs.google.com/spreadsheets/d/{WORKBOOK_ID}/export?format=xlsx"
|
||||||
|
response = requests.get(export_url, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
sheets = []
|
||||||
|
for name in sheet_names(response.content):
|
||||||
|
headers, rows = normalize_sheet(name, read_gviz(name))
|
||||||
|
if headers:
|
||||||
|
sheets.append((name, headers, rows))
|
||||||
|
headers, rows = merge_sheets(sheets)
|
||||||
|
publish(render_html(headers, rows))
|
||||||
|
|
||||||
|
|
||||||
|
def notify_failure(error: Exception) -> None:
|
||||||
|
url = os.getenv("SYNC_FAILURE_WEBHOOK_URL")
|
||||||
|
if not url:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
requests.post(url, json={"text": f"TTSHOP sync failed: {type(error).__name__}"}, timeout=10)
|
||||||
|
except requests.RequestException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
sync()
|
||||||
|
except Exception as error:
|
||||||
|
notify_failure(error)
|
||||||
|
print(f"sync failed: {error}", file=sys.stderr)
|
||||||
|
raise
|
||||||
Reference in New Issue
Block a user