2026-05-22 23:05:20 +08:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""
|
|
|
|
|
|
Notion ↔ 代码仓库双向同步器
|
2026-05-22 23:12:05 +08:00
|
|
|
|
对接数据库:⚡ 半体工单 · Half-Body Orders (05719bae-9e53-40f4-8233-6bf54551ac18)
|
2026-05-22 23:05:20 +08:00
|
|
|
|
同步周期:30分钟轮询
|
|
|
|
|
|
|
|
|
|
|
|
依赖:
|
|
|
|
|
|
pip install requests
|
|
|
|
|
|
|
|
|
|
|
|
启动:
|
2026-05-22 23:12:05 +08:00
|
|
|
|
export NOTION_TOKEN="xxx"
|
|
|
|
|
|
export NOTION_DATABASE_ID="05719bae-9e53-40f4-8233-6bf54551ac18"
|
2026-05-22 23:05:20 +08:00
|
|
|
|
pm2 start notion/sync-ticket.py --name notion-ticket-sync
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-05-22 23:12:05 +08:00
|
|
|
|
import os, sys, json, time, subprocess
|
2026-05-22 23:05:20 +08:00
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
import requests
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
print('[Sync] 请先安装 requests: pip install requests')
|
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
# ── 配置 ──
|
|
|
|
|
|
NOTION_TOKEN = os.environ.get('NOTION_TOKEN')
|
2026-05-22 23:12:05 +08:00
|
|
|
|
NOTION_DATABASE_ID = os.environ.get('NOTION_DATABASE_ID') or '05719bae-9e53-40f4-8233-6bf54551ac18'
|
2026-05-22 23:05:20 +08:00
|
|
|
|
REPO_PATH = '/opt/guanghulab-repo'
|
|
|
|
|
|
CACHE_FILE = os.path.join(REPO_PATH, 'notion/tickets-cache.json')
|
2026-05-22 23:12:05 +08:00
|
|
|
|
INTERVAL = int(os.environ.get('SYNC_INTERVAL', '1800')) # 30分钟
|
2026-05-22 23:05:20 +08:00
|
|
|
|
|
|
|
|
|
|
NOTION_API = 'https://api.notion.com/v1'
|
|
|
|
|
|
HEADERS = {
|
|
|
|
|
|
'Authorization': f'Bearer {NOTION_TOKEN}',
|
|
|
|
|
|
'Notion-Version': '2022-06-28',
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 23:12:05 +08:00
|
|
|
|
# 负责Agent选项(含铸渊)
|
|
|
|
|
|
AGENTS = ['译典A05', '培园A04', '录册A02', '霜砚Web', '铸渊']
|
|
|
|
|
|
|
2026-05-22 23:05:20 +08:00
|
|
|
|
|
|
|
|
|
|
def log(msg):
|
|
|
|
|
|
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
|
|
print(f'[{ts}] {msg}', flush=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 23:12:05 +08:00
|
|
|
|
def query_notion_tickets():
|
|
|
|
|
|
"""查询Notion半体工单数据库"""
|
|
|
|
|
|
if not NOTION_TOKEN:
|
|
|
|
|
|
log('[跳过] 未配置 NOTION_TOKEN')
|
2026-05-22 23:05:20 +08:00
|
|
|
|
return []
|
|
|
|
|
|
url = f'{NOTION_API}/databases/{NOTION_DATABASE_ID}/query'
|
|
|
|
|
|
body = {
|
|
|
|
|
|
'page_size': 50,
|
|
|
|
|
|
'sorts': [{'timestamp': 'last_edited_time', 'direction': 'descending'}]
|
|
|
|
|
|
}
|
|
|
|
|
|
try:
|
|
|
|
|
|
res = requests.post(url, headers=HEADERS, json=body, timeout=15)
|
|
|
|
|
|
if res.status_code == 401:
|
2026-05-22 23:12:05 +08:00
|
|
|
|
log('[错误] Notion token 无效或已过期')
|
2026-05-22 23:05:20 +08:00
|
|
|
|
return []
|
|
|
|
|
|
if not res.ok:
|
2026-05-22 23:12:05 +08:00
|
|
|
|
log(f'[错误] Notion {res.status_code}')
|
2026-05-22 23:05:20 +08:00
|
|
|
|
return []
|
|
|
|
|
|
data = res.json()
|
|
|
|
|
|
results = data.get('results', [])
|
|
|
|
|
|
log(f'查询到 {len(results)} 条工单')
|
|
|
|
|
|
return results
|
|
|
|
|
|
except Exception as e:
|
2026-05-22 23:12:05 +08:00
|
|
|
|
log(f'[异常] {e}')
|
2026-05-22 23:05:20 +08:00
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 23:12:05 +08:00
|
|
|
|
def get_my_tickets(tickets):
|
|
|
|
|
|
"""找出分配给铸渊或未分配的工单"""
|
|
|
|
|
|
mine = []
|
|
|
|
|
|
for t in tickets:
|
|
|
|
|
|
props = t.get('properties', {})
|
|
|
|
|
|
agent = props.get('负责Agent', {}).get('select', {}).get('name', '')
|
|
|
|
|
|
status = props.get('状态', {}).get('select', {}).get('name', '')
|
|
|
|
|
|
title = props.get('任务标题', {}).get('title', [{}])[0].get('plain_text', '无标题')
|
|
|
|
|
|
if agent in ('铸渊', '') and status not in ('已完成', '暂缓'):
|
|
|
|
|
|
mine.append({
|
|
|
|
|
|
'page_id': t['id'],
|
2026-05-22 23:05:20 +08:00
|
|
|
|
'title': title,
|
2026-05-22 23:12:05 +08:00
|
|
|
|
'status': status,
|
|
|
|
|
|
'agent': agent,
|
|
|
|
|
|
'url': t.get('url', ''),
|
|
|
|
|
|
'code': props.get('编号', {}).get('rich_text', [{}])[0].get('plain_text', '') if not props.get('编号', {}).get('type') else ''
|
2026-05-22 23:05:20 +08:00
|
|
|
|
})
|
2026-05-22 23:12:05 +08:00
|
|
|
|
return mine
|
2026-05-22 23:05:20 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 23:12:05 +08:00
|
|
|
|
def save_cache(tickets):
|
2026-05-22 23:05:20 +08:00
|
|
|
|
try:
|
|
|
|
|
|
with open(CACHE_FILE, 'w') as f:
|
|
|
|
|
|
json.dump({
|
|
|
|
|
|
'last_sync': datetime.now().isoformat(),
|
|
|
|
|
|
'count': len(tickets),
|
|
|
|
|
|
'tickets': [{
|
|
|
|
|
|
'id': t['id'],
|
2026-05-22 23:12:05 +08:00
|
|
|
|
'title': t.get('properties', {}).get('任务标题', {}).get('title', [{}])[0].get('plain_text', ''),
|
|
|
|
|
|
'status': t.get('properties', {}).get('状态', {}).get('select', {}).get('name', '')
|
2026-05-22 23:05:20 +08:00
|
|
|
|
} for t in tickets]
|
|
|
|
|
|
}, f, ensure_ascii=False, indent=2)
|
|
|
|
|
|
except Exception as e:
|
2026-05-22 23:12:05 +08:00
|
|
|
|
log(f'[警告] 缓存失败: {e}')
|
2026-05-22 23:05:20 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sync_cycle():
|
|
|
|
|
|
log('=' * 50)
|
|
|
|
|
|
log('开始同步周期')
|
|
|
|
|
|
|
2026-05-22 23:12:05 +08:00
|
|
|
|
tickets = query_notion_tickets()
|
2026-05-22 23:05:20 +08:00
|
|
|
|
if not tickets:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-05-22 23:12:05 +08:00
|
|
|
|
mine = get_my_tickets(tickets)
|
|
|
|
|
|
if mine:
|
|
|
|
|
|
log(f'铸渊相关工单: {len(mine)} 条')
|
|
|
|
|
|
for item in mine:
|
|
|
|
|
|
log(f' → {item["title"]} [{item["status"]}]')
|
2026-05-22 23:05:20 +08:00
|
|
|
|
else:
|
2026-05-22 23:12:05 +08:00
|
|
|
|
log('无铸渊相关工单')
|
2026-05-22 23:05:20 +08:00
|
|
|
|
|
2026-05-22 23:12:05 +08:00
|
|
|
|
save_cache(tickets)
|
2026-05-22 23:05:20 +08:00
|
|
|
|
log('同步周期完成')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
|
|
log(f'Notion ↔ 代码仓库同步器启动')
|
2026-05-22 23:12:05 +08:00
|
|
|
|
log(f'轮询间隔: {INTERVAL}秒')
|
|
|
|
|
|
log(f'数据库: {NOTION_DATABASE_ID}')
|
2026-05-22 23:05:20 +08:00
|
|
|
|
|
2026-05-22 23:12:05 +08:00
|
|
|
|
if not NOTION_TOKEN:
|
|
|
|
|
|
log('⚠️ 未配置 NOTION_TOKEN')
|
|
|
|
|
|
log('请设置: export NOTION_TOKEN="secret_xxx"')
|
2026-05-22 23:05:20 +08:00
|
|
|
|
|
|
|
|
|
|
sync_cycle()
|
|
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
time.sleep(INTERVAL)
|
|
|
|
|
|
try:
|
|
|
|
|
|
sync_cycle()
|
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
|
log('同步器停止')
|
|
|
|
|
|
break
|
|
|
|
|
|
except Exception as e:
|
2026-05-22 23:12:05 +08:00
|
|
|
|
log(f'[严重] {e}')
|
|
|
|
|
|
import traceback; traceback.print_exc()
|