Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/update_data.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Notion to JSON Sync

on:
workflow_dispatch: # Run manually when recovery is needed

jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"

- name: Install dependencies
run: |
pip install requests python-slugify urllib3

- name: Run Sync Script
env:
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
DATABASE_ID: ${{ secrets.DATABASE_ID }}
run: python scripts/fetch-members.py

- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "[BOT] Update members from Notion"

branch: notion-data-update
delete-branch: true # Auto delete branch after PR is merged
title: "PR: Update members from Notion"
base: main
body: |
member data has been synchronized from Notion. Changes include:
- Update file: `src/data/notion_member.json`
- Update images in: `public/member_images/`
reviewers: thu4n, hlocuwu
1 change: 1 addition & 0 deletions notion_cache.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"1aabb9ea-bd14-8003-94cb-e05052c8e884": "2026-01-16T07:24:00.000Z", "1aabb9ea-bd14-801b-b84e-e5e6759bab17": "2026-01-19T15:32:00.000Z", "1aabb9ea-bd14-8034-ac22-eb67957b287c": "2025-12-30T15:43:00.000Z", "1aabb9ea-bd14-804b-980b-d17b3028bda5": "2025-12-27T14:02:00.000Z", "1aabb9ea-bd14-805e-bffa-cad47843be2b": "2025-12-30T16:56:00.000Z", "1aabb9ea-bd14-8064-9817-f1b7ee3cd7d8": "2025-12-30T13:56:00.000Z", "1aabb9ea-bd14-807b-8eec-e2f5da97fc9d": "2026-01-21T16:28:00.000Z", "1aabb9ea-bd14-80b4-9d41-ed343f0f84fb": "2025-12-15T04:50:00.000Z", "1aabb9ea-bd14-80c4-b066-c6241af213d8": "2025-12-30T13:57:00.000Z", "1aabb9ea-bd14-80d4-bcae-c47505f078f7": "2025-12-30T15:03:00.000Z", "1aabb9ea-bd14-80fc-b09d-f119168392b0": "2025-12-26T13:14:00.000Z", "1aabb9ea-bd14-80fe-89eb-f5b5495943ef": "2025-12-28T07:30:00.000Z", "2c9bb9ea-bd14-8111-a197-fd2b924a628c": "2025-12-27T09:20:00.000Z", "2c9bb9ea-bd14-8157-a1b2-e2ba27313a99": "2025-12-29T16:31:00.000Z", "2c9bb9ea-bd14-815b-adb0-c3c3e1227924": "2025-12-29T03:38:00.000Z", "2cabb9ea-bd14-8127-a5b5-d0c4be038074": "2025-12-27T09:20:00.000Z", "2cabb9ea-bd14-8155-867c-c90aef7e6ddf": "2025-12-30T01:35:00.000Z"}
26 changes: 13 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"react-dom": "18.3.1",
"react-helmet-async": "^2.0.5",
"react-icons": "^5.5.0",
"react-router-dom": "^6.30.1",
"react-router-dom": "^6.30.3",
"three": "^0.180.0",
"web-vitals": "^2.1.4"
},
Expand Down
79 changes: 52 additions & 27 deletions public/index.html
Original file line number Diff line number Diff line change
@@ -1,51 +1,76 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes"
/>
<meta name="theme-color" content="#000000" />
<meta name="description" content="SVUIT - MMTT - Nền tảng tài nguyên học tập cho sinh viên UIT" />
<meta
name="description"
content="SVUIT - MMTT - Nền tảng tài nguyên học tập cho sinh viên UIT"
/>
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta name="HandheldFriendly" content="true" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="format-detection" content="telephone=no" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self';
script-src 'self';
style-src 'self' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data: https:;
connect-src 'self';
object-src 'none';
frame-ancestors 'none';
base-uri 'self';">
<meta name="referrer" content="strict-origin-when-cross-origin">

<meta
http-equiv="Content-Security-Policy"
content="
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com;
font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com;
img-src 'self' data:;
connect-src 'self';
"
/>
<meta name="referrer" content="strict-origin-when-cross-origin" />

<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />

<title>SVUIT - MMTT</title>

<!-- Preconnect to external domains -->
<link rel="preconnect" href="https://cdnjs.cloudflare.com" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw=="
crossorigin="anonymous"
referrerpolicy="no-referrer" />

<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>

<!-- Preload critical CSS -->
<link rel="preload" href="%PUBLIC_URL%/css/main.css" as="style" />

<!-- Preload critical fonts -->
<link rel="preload" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"></noscript>
<link
rel="preload"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
as="style"
onload="
this.onload = null;
this.rel = 'stylesheet';
"
/>
<noscript
><link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/></noscript>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
Expand Down
Binary file added public/member_images/dang_chi_thanh.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/member_images/doan_nguyen_lam.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/member_images/doan_nguyen_minh_thu.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/member_images/doan_quoc_an.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/member_images/ly_nhat_minh.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/member_images/ngo_le_tan_huy.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/member_images/nguyen_hoang_loc.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/member_images/nguyen_thanh_an.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/member_images/nguyen_the_lap.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/member_images/pham_viet_hoang.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/member_images/son_nguyen_ky_duyen.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/member_images/tong_vo_anh_thuan.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/member_images/tran_cong_hai.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/member_images/tran_pham_bao_tran.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/member_images/trinh_hoai_an.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/member_images/truong_do_nhu_quynh.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
128 changes: 128 additions & 0 deletions scripts/fetch-members.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import requests, json, time, os
from slugify import slugify
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

# --- Set up ---
NOTION_TOKEN = os.environ.get("NOTION_TOKEN")
DATABASE_ID = os.environ.get("DATABASE_ID")
IMAGE_FOLDER = "public/member_images"
HEADERS = {
"Authorization": f"Bearer {NOTION_TOKEN}",
"Content-Type": "application/json",
"Notion-Version": "2022-06-28",
}
# Check required
if not NOTION_TOKEN or not DATABASE_ID:
print("❌ Missing NOTION_TOKEN or DATABASE_ID in environment variables!")
exit(1)

# Set up session with retry strategy
session = requests.Session()
retry_strategy = Retry(
total=3, # Max retries: 3
backoff_factor=1, # Wait 1s, 2s, 4s between retries
status_forcelist=[429, 500, 502, 503, 504], # Retry on these status codes
)
session.mount("https://", HTTPAdapter(max_retries=retry_strategy))

def download_image(url, name, force=False):
default_path = '/default.png'
if not url: return default_path
os.makedirs(IMAGE_FOLDER, exist_ok=True)

filename = f"{slugify(name, separator='_')}.jpg"
save_path = os.path.join(IMAGE_FOLDER, filename)
temp_path = save_path + ".tmp" # Use temporary file during download
web_path = f"/member_images/{filename}"

if not force and os.path.exists(save_path) and os.path.getsize(save_path) > 1024:
return web_path

try:
# Use session with pre-configured retry strategy
with session.get(url, stream=True, timeout=20) as res:
res.raise_for_status()
# Download chunks and write to temporary file
with open(temp_path, 'wb') as f:
for chunk in res.iter_content(chunk_size=8192):
if chunk: f.write(chunk)

# Validate if the downloaded file is too small (likely corrupted)
if os.path.getsize(temp_path) < 100:
raise Exception("File too small, download might have failed")

# Replace existing file only after successful download
if os.path.exists(save_path): os.remove(save_path)
os.rename(temp_path, save_path)

print(f"✅ Downloaded: {filename}")
return web_path
except Exception as e:
if os.path.exists(temp_path): os.remove(temp_path)
print(f"❌ Error downloading image for {name}: {e}")
return web_path if os.path.exists(save_path) else None

def get_body_image(page_id):
try:
res = session.get(f"https://api.notion.com/v1/blocks/{page_id}/children", headers=HEADERS)
for b in res.json().get("results", []):
if b["type"] == "image":
img = b["image"]
return img["file"]["url"] if img["type"] == "file" else img["external"]["url"]
except: pass
return None

def process_notion_data():
res = session.post(f"https://api.notion.com/v1/databases/{DATABASE_ID}/query", headers=HEADERS)
if res.status_code != 200: return print(f"❌ Error: {res.text}")

pages = res.json().get("results", [])
cache_file = "notion_cache.json"
final_data = []
cache = {}
if os.path.exists(cache_file):
try:
with open(cache_file, "r", encoding="utf-8") as f:
cache = json.load(f)
except:
cache = {}
new_cache = {}

for page in pages:
p_id, last_edit = page["id"], page["last_edited_time"]
props = page["properties"]

name_obj = props.get("Your Name", {}).get("title", [])
name = name_obj[0]["plain_text"] if name_obj else "unnamed"

filename = f"{slugify(name, separator='_')}.jpg"
local_path = os.path.join(IMAGE_FOLDER, filename)

# Determine if data has changed or file is missing
is_changed = cache.get(p_id) != last_edit
is_missing = not os.path.exists(local_path) or os.path.getsize(local_path) < 1024

if is_changed or is_missing:
img_url = get_body_image(p_id)
img_path = download_image(img_url, name, force=is_changed)
# Update cache only if download was successful
new_cache[p_id] = last_edit if img_path else cache.get(p_id)
time.sleep(0.2) # Slight delay to avoid Notion rate limiting
else:
img_path, new_cache[p_id] = f"/member_images/{filename}", last_edit

final_data.append({
"name": name,
"role": props.get("Role", {}).get("select", {}).get("name", "N/A"),
"image": img_path
})

os.makedirs("src/data", exist_ok=True)
with open(cache_file, "w") as f: json.dump(new_cache, f)
with open("src/data/notion_member.json", "w", encoding="utf-8") as f:
json.dump(final_data, f, ensure_ascii=False, indent=4)
print("🚀 completed successfully!")

if __name__ == "__main__":
process_notion_data()
Loading