自動拉取各大OJ的比賽日程并導入日歷軟件
參考文章:使用日歷app輕松訂閱各大OJ平臺上的比賽(ics格式)
tips:代碼為Grok3生成,能跑。
過去看比賽日期的方式都弱爆了!需要自己手動打開各OJ的網頁,有時忘看了還會錯過比賽,而現在我們再也不需要擔心這個問題了。
預覽


正文
快速開始
如果你不想自己部署,可以使用我的訂閱鏈接,但維護有成本(SCF云函數,域名,對象存儲等),我或許不會維護很久。
Win電腦與Android手機
在Outlook注冊一個Outlook賬號,如果已經有就登錄,手機用戶需要下載Outlook軟件。
登入后,在左邊點擊日歷按鈕,點擊左側的“添加日歷”,點擊“Web訂閱”,填入我的訂閱鏈接:
https://oss.misaka2298.icu/oss/calendar.ics
點擊導入,電腦端的操作到這里就結束了,注意上方的可視范圍從默認的"工作周"切換為"周",不然看不到周末的比賽。
手機端需要多一步操作:在Outlook的設置里找到“日歷”設置,勾選“同步日歷”,并同意Outlook申請的日歷訪問權限,然后手機日歷就會自動同步了。
iPhone
我手頭沒有iPhone機器,這里引用我參考文章里的步驟:
- 打開ios日歷,點擊添加日歷 - 添加訂閱日歷
- 粘貼鏈接
- 進行自定義設置,完成
鏈接同https://oss.misaka2298.icu/oss/calendar.ics
自己部署
可能產生的費用
騰訊云SCF函數:個人標準版函數套餐12.8元/月。
域名:冷門頂級域名(如我的.icu)約100/年,首年優惠。當然也可以選用網上公益的二級域名服務,請自行搜索。
對象存儲:
- CloudFlareR2(本文使用):基本免費,但需要一張銀行卡(支持銀聯)
- 其他對象存儲:按量收費,如果訪問量大可能產生較高的費用。
如果可以接受的話,下面是教程。
環境安裝
首先,需要Python3.9的環境(截止到本文發布),安裝時記得勾選"Add to PATH",安裝后重啟。
找個空文件夾,打開cmd,執行下面的命令:
mkdir layer
cd layer
python -m pip install requests==2.28.2 beautifulsoup4==4.12.3 ics==0.7.1 boto3==1.34.149 urllib3==1.26.18 tatsu==5.7.4 -t .
把layer文件夾壓縮成zip,備用。
對象存儲
打開CloudFlare控制臺,沒有賬號就注冊一個,在左側選項卡找到R2對象存儲,按提示初始化,注意需要銀行卡。
當然如果你要用其他對象存儲服務商的話可以自行研究。
點擊{}API,點擊管理API令牌,然后創建UserAPI令牌,權限為管理員讀寫,名稱自己取,然后記住你的訪問密鑰 ID和機密訪問密鑰,注意這兩個東西只會出現一次。
返回R2控制臺,創建新的存儲桶,名稱自己起,位置選亞太,除非你在外國。
進入存儲桶,在設置中添加自定義域,這里不再贅述,網上也有很多公益的二級域名供使用,請自行搜索教程。
SCF自動拉取
打開騰訊云SCF控制臺,沒注冊的話注冊一個。
點擊左側“函數服務”,點擊新建。
點擊"從頭開始",函數類型選事件函數,名稱自己起,地域無所謂,運行環境python3.9,時區UTC。
在下方粘貼我的代碼:
import json
import requests
import re
import datetime
import urllib
from bs4 import BeautifulSoup
import ics
import boto3
from botocore.client import Config
import os
import time
R2_ACCESS_KEY = os.environ.get('R2_ACCESS_KEY', '')
R2_SECRET_KEY = os.environ.get('R2_SECRET_KEY', '')
R2_ENDPOINT_URL = os.environ.get('R2_ENDPOINT_URL', '')
R2_BUCKET_NAME = os.environ.get('R2_BUCKET_NAME', '')
R2_OBJECT_NAME = os.environ.get('R2_OBJECT_NAME', 'calendar.ics')
def get_luogu_contests():
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36 QIHU 360SE'
}
try:
start_time = time.time()
res = requests.get('https://www.luogu.com.cn/contest/list', headers=headers, timeout=5)
print(f"洛谷請求耗時 {time.time() - start_time:.2f} 秒")
mat = re.findall(r'JSON\.parse\(decodeURIComponent\("(\S+)"\)', res.text)[0]
mat = json.loads(urllib.parse.unquote(mat))
contests = []
for t in mat['currentData']['contests']['result']:
title = t['name']
start_time = datetime.datetime.fromtimestamp(t['startTime'])
end_time = datetime.datetime.fromtimestamp(t['endTime'])
start_time = start_time + datetime.timedelta(hours=-8)
end_time = end_time + datetime.timedelta(hours=-8)
contests.append({"title": title, "start": start_time, "end": end_time})
return contests
except Exception as e:
print(f"洛谷抓取失敗: {e}")
return []
def get_atcoder_contests():
try:
start_time = time.time()
url = "https://atcoder.jp/contests/"
res = requests.get(url, timeout=5)
print(f"AtCoder 請求耗時 {time.time() - start_time:.2f} 秒")
soup = BeautifulSoup(res.text, 'html.parser')
contests = []
for row in soup.select('#contest-table-upcoming tbody tr'):
cols = row.find_all('td')
start_time = datetime.datetime.strptime(cols[0].text, '%Y-%m-%d %H:%M:%S%z')
title = cols[1].text.strip()
title = re.sub(r'[^\w\s\-\(\)]', '', title).strip()
duration = cols[2].text.strip()
hours, minutes = map(int, duration.split(':'))
end_time = start_time + datetime.timedelta(hours=hours, minutes=minutes)
contests.append({"title": title, "start": start_time, "end": end_time})
return contests
except Exception as e:
print(f"AtCoder 抓取失敗: {e}")
return []
def get_codeforces_contests():
try:
start_time = time.time()
url = "https://codeforces.com/api/contest.list?gym=false"
res = requests.get(url, timeout=5)
print(f"Codeforces 請求耗時 {time.time() - start_time:.2f} 秒")
data = res.json()
contests = []
for contest in data['result']:
if contest['phase'] == 'BEFORE':
title = contest['name']
start_time = datetime.datetime.fromtimestamp(contest['startTimeSeconds'], datetime.timezone.utc)
duration = contest['durationSeconds']
end_time = start_time + datetime.timedelta(seconds=duration)
contests.append({"title": title, "start": start_time, "end": end_time})
return contests
except Exception as e:
print(f"Codeforces 抓取失敗: {e}")
return []
def main_handler(event, context):
start_time = time.time()
print("函數開始執行")
registered = [
get_luogu_contests(),
get_atcoder_contests(),
get_codeforces_contests()
]
print(f"數據抓取總耗時 {time.time() - start_time:.2f} 秒")
calendar = ics.Calendar()
calendar_start = time.time()
for dat in registered:
if dat:
for res in dat:
print(res['title'], '|', res['start'], '|', res['end'])
e = ics.Event()
e.name = res['title']
e.begin = res['start']
e.end = res['end']
calendar.events.add(e)
print(f"日歷創建耗時 {time.time() - calendar_start:.2f} 秒")
ics_content = calendar.serialize()
try:
upload_start = time.time()
s3 = boto3.client('s3',
endpoint_url=R2_ENDPOINT_URL,
aws_access_key_id=R2_ACCESS_KEY,
aws_secret_access_key=R2_SECRET_KEY,
config=Config(signature_version='s3v4'))
s3.put_object(Bucket=R2_BUCKET_NAME, Key=R2_OBJECT_NAME, Body=ics_content, ContentType='text/calendar', ACL='public-read')
print(f"ICS 文件上傳到 Cloudflare R2 成功,耗時 {time.time() - upload_start:.2f} 秒")
except Exception as e:
print(f"上傳到 R2 失敗: {e}")
return {
'statusCode': 500,
'body': json.dumps({'error': str(e)})
}
print(f"總執行時間: {time.time() - start_time:.2f} 秒")
return {
'statusCode': 200,
'body': json.dumps({'message': 'ICS 文件生成并上傳成功'})
}
if __name__ == '__main__':
main_handler({}, {})
在12~17行填寫你CloudFlare存儲桶的信息:
R2_ACCESS_KEY:APIKey的訪問密鑰 IDR2_SECRET_KEY:APIKey的機密訪問密鑰R2_ENDPOINT_URL:你的存儲桶 - 設置 - S3API的那一串鏈接R2_BUCKET_NAME:存儲桶名R2_OBJECT_NAME:保存的文件名,擴展名為.ics
拉到最底下,在觸發器配置中勾選自定義創建,觸發別名/版本中選擇版本:$LATEST,觸發周期選擇每一天。
然后,在高級配置中把執行超時時間改為10秒。
同意協議,點擊完成。
返回SCF控制臺,在左側進入層的配置頁面。
點擊新建,層名稱隨便寫,層代碼為你前面打包的layer.zip,運行環境添加Python3.9,點擊確定。
在左側進入函數服務的配置界面,進入你剛創建的函數,在上方進入層管理,點擊綁定,綁定你剛創建的層。
點擊上方的函數代碼,點擊下方的測試,觀察執行摘要中的返回結果,如果一切順利,這里應該是
{"statusCode": 200, "body": "{\"message\": \"ICS \文\件\生\成\并\上\傳\成\功\"}"}
看看部署結果
返回CloudFlareR2控制臺,進入存儲桶,尋找你生成的calender.ics。
如果存在的話,復制它的自定義域。
復制后在瀏覽器打開你復制的鏈接,如果可以下載就是成功了,導入教程同上。
以后或許會實現的功能
- 更多OJ的拉取
- 在標題上標注是否Rated
- 成本更低的部署
- MacOS的導入(當然應該是支持的,可以自己摸索)

浙公網安備 33010602011771號