Skip to content

Commit 0db2435

Browse files
committed
添加Cloudflare Worker代理,恢复水鱼Import-Token和Developer-Token模式
1 parent 32aad36 commit 0db2435

6 files changed

Lines changed: 181 additions & 33 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ on:
55
branches: [master]
66
paths:
77
- 'web/**'
8+
- 'worker/**'
89
- '.github/workflows/deploy.yml'
910
workflow_dispatch:
1011

@@ -18,6 +19,29 @@ concurrency:
1819
cancel-in-progress: true
1920

2021
jobs:
22+
deploy-worker:
23+
runs-on: ubuntu-latest
24+
if: ${{ secrets.CLOUDFLARE_API_TOKEN != '' }}
25+
steps:
26+
- uses: actions/checkout@v4
27+
28+
- uses: actions/setup-node@v4
29+
with:
30+
node-version: 20
31+
cache: npm
32+
cache-dependency-path: worker/package-lock.json
33+
34+
- name: Install dependencies
35+
run: npm ci || npm install
36+
working-directory: worker
37+
38+
- name: Deploy Worker
39+
run: npx wrangler deploy
40+
working-directory: worker
41+
env:
42+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
43+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
44+
2145
build:
2246
runs-on: ubuntu-latest
2347
steps:
@@ -39,6 +63,7 @@ jobs:
3963
env:
4064
VITE_LXNS_DEVELOPER_TOKEN: ${{ secrets.VITE_LXNS_DEVELOPER_TOKEN }}
4165
VITE_SHUIYU_DEVELOPER_TOKEN: ${{ secrets.VITE_SHUIYU_DEVELOPER_TOKEN }}
66+
VITE_WORKER_PROXY_URL: ${{ secrets.VITE_WORKER_PROXY_URL }}
4267

4368
- uses: actions/upload-pages-artifact@v3
4469
with:

web/src/types/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,5 +81,5 @@ export interface Playlog {
8181
[key: string]: unknown
8282
}
8383

84-
export type ApiMode = 'lxns' | 'lxns-dev' | 'shuiyu'
84+
export type ApiMode = 'lxns' | 'lxns-dev' | 'shuiyu-import' | 'shuiyu-dev'
8585
export type CsvFormat = 'auto' | 'lxns' | 'shuiyu'

web/src/utils/api.ts

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { calculateRankFromScore } from './converter'
33

44
const LXNS_BASE_URL = 'https://maimai.lxns.net/api/v0'
55
const SHUIYU_BASE_URL = 'https://www.diving-fish.com/api/chunithmprober'
6+
const WORKER_PROXY = import.meta.env.VITE_WORKER_PROXY_URL || ''
67

78
async function fetchJson(url: string, headers: Record<string, string>): Promise<unknown> {
89
const res = await fetch(url, {
@@ -20,23 +21,13 @@ async function fetchJson(url: string, headers: Record<string, string>): Promise<
2021
return res.json()
2122
}
2223

23-
async function postJson(url: string, body: Record<string, unknown>): Promise<unknown> {
24-
const res = await fetch(url, {
25-
method: 'POST',
26-
headers: { 'Content-Type': 'application/json' },
27-
body: JSON.stringify(body),
28-
})
29-
30-
if (!res.ok) {
31-
if (res.status === 400) {
32-
const data = await res.json().catch(() => ({})) as Record<string, unknown>
33-
throw new Error((data['message'] as string) || '请求失败')
34-
}
35-
if (res.status === 403) throw new Error('该用户已设置隐私或未同意用户协议')
36-
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
37-
}
24+
function proxyUrl(url: string): string {
25+
if (!WORKER_PROXY) throw new Error('此站点未配置 CORS 代理,水鱼 Import-Token / Developer-Token 模式不可用')
26+
return `${WORKER_PROXY}?url=${encodeURIComponent(url)}`
27+
}
3828

39-
return res.json()
29+
async function fetchViaProxy(url: string, headers: Record<string, string>): Promise<unknown> {
30+
return fetchJson(proxyUrl(url), headers)
4031
}
4132

4233
function parseLxnsPlayer(data: Record<string, unknown>): PlayerInfo {
@@ -136,6 +127,8 @@ export async function fetchFromApi(mode: ApiMode, options: {
136127
lxnsToken?: string
137128
lxnsDeveloperToken?: string
138129
lxnsFriendCode?: number
130+
shuiyuImportToken?: string
131+
shuiyuDeveloperToken?: string
139132
shuiyuUsername?: string
140133
onProgress?: (msg: string) => void
141134
}): Promise<ApiFetchResult> {
@@ -188,13 +181,30 @@ export async function fetchFromApi(mode: ApiMode, options: {
188181
return { player, scores }
189182
}
190183

191-
if (mode === 'shuiyu') {
192-
if (!options.shuiyuUsername) throw new Error('水鱼查询模式需要用户名')
184+
if (mode === 'shuiyu-import') {
185+
if (!options.shuiyuImportToken) throw new Error('水鱼个人模式需要 Import-Token')
186+
const headers = { 'Import-Token': options.shuiyuImportToken }
187+
188+
progress('正在通过代理获取玩家成绩...')
189+
const data = await fetchViaProxy(`${SHUIYU_BASE_URL}/player/records`, headers) as Record<string, unknown>
190+
const player = parseShuiyuPlayer(data)
191+
const records = ((data['records'] as Record<string, unknown>)?.['best'] || []) as Record<string, unknown>[]
192+
const scores = records.map(parseShuiyuScore)
193+
194+
progress(`获取到 ${scores.length} 条成绩`)
195+
return { player, scores }
196+
}
197+
198+
if (mode === 'shuiyu-dev') {
199+
if (!options.shuiyuDeveloperToken) throw new Error('水鱼开发者模式需要 Developer-Token')
200+
if (!options.shuiyuUsername) throw new Error('水鱼开发者模式需要用户名')
201+
const headers = { 'Developer-Token': options.shuiyuDeveloperToken }
193202

194-
progress(`正在查询玩家 "${options.shuiyuUsername}" 的成绩...`)
195-
const data = await postJson(`${SHUIYU_BASE_URL}/query/player`, {
196-
username: options.shuiyuUsername,
197-
}) as Record<string, unknown>
203+
progress(`正在通过代理获取玩家 "${options.shuiyuUsername}" 的成绩...`)
204+
const data = await fetchViaProxy(
205+
`${SHUIYU_BASE_URL}/dev/player/records?username=${encodeURIComponent(options.shuiyuUsername)}`,
206+
headers,
207+
) as Record<string, unknown>
198208
const player = parseShuiyuPlayer(data)
199209
const records = ((data['records'] as Record<string, unknown>)?.['best'] || []) as Record<string, unknown>[]
200210
const scores = records.map(parseShuiyuScore)

web/src/views/ConverterView.vue

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,23 @@ const apiForm = reactive({
1616
lxnsToken: '',
1717
lxnsDeveloperToken: import.meta.env.VITE_LXNS_DEVELOPER_TOKEN || '',
1818
lxnsFriendCode: '',
19+
shuiyuImportToken: '',
20+
shuiyuDeveloperToken: import.meta.env.VITE_SHUIYU_DEVELOPER_TOKEN || '',
1921
shuiyuUsername: ''
2022
})
2123
22-
// CSV Form state
23-
const csvFormat = ref<CsvFormat>('auto')
24-
const csvUsername = ref('Player')
25-
const csvFile = ref<File | null>(null)
26-
2724
const apiModeOptions = [
2825
{ label: '落雪个人 (通过 Token 获取个人数据)', value: 'lxns' },
2926
{ label: '落雪开发者 (通过好友码获取他人数据)', value: 'lxns-dev' },
30-
{ label: '水鱼查询 (通过用户名获取成绩)', value: 'shuiyu' }
27+
{ label: '水鱼个人 (通过 Import-Token 获取完整数据)', value: 'shuiyu-import' },
28+
{ label: '水鱼开发者 (通过 Developer-Token 获取数据)', value: 'shuiyu-dev' },
3129
]
3230
31+
// CSV Form state
32+
const csvFormat = ref<CsvFormat>('auto')
33+
const csvUsername = ref('Player')
34+
const csvFile = ref<File | null>(null)
35+
3336
const csvFormatOptions = [
3437
{ label: '自动检测', value: 'auto' },
3538
{ label: '落雪格式', value: 'lxns' },
@@ -76,6 +79,8 @@ const handleApiConversion = async () => {
7679
lxnsToken: apiForm.lxnsToken,
7780
lxnsDeveloperToken: apiForm.lxnsDeveloperToken,
7881
lxnsFriendCode: friendCode,
82+
shuiyuImportToken: apiForm.shuiyuImportToken,
83+
shuiyuDeveloperToken: apiForm.shuiyuDeveloperToken,
7984
shuiyuUsername: apiForm.shuiyuUsername,
8085
onProgress: addProgress
8186
})
@@ -170,7 +175,8 @@ const isApiFormValid = computed(() => {
170175
switch (apiMode.value) {
171176
case 'lxns': return !!apiForm.lxnsToken
172177
case 'lxns-dev': return !!apiForm.lxnsDeveloperToken && !!apiForm.lxnsFriendCode
173-
case 'shuiyu': return !!apiForm.shuiyuUsername
178+
case 'shuiyu-import': return !!apiForm.shuiyuImportToken
179+
case 'shuiyu-dev': return !!apiForm.shuiyuDeveloperToken && !!apiForm.shuiyuUsername
174180
default: return false
175181
}
176182
})
@@ -209,13 +215,27 @@ const isApiFormValid = computed(() => {
209215
</n-form-item>
210216
</template>
211217

212-
<n-form-item v-if="apiMode === 'shuiyu'" label="水鱼用户名">
218+
<n-form-item v-if="apiMode === 'shuiyu-import'" label="水鱼 Import-Token">
213219
<n-input
214-
v-model:value="apiForm.shuiyuUsername"
215-
placeholder="输入目标玩家的水鱼用户名"
220+
v-model:value="apiForm.shuiyuImportToken"
221+
type="password"
222+
show-password-on="click"
223+
placeholder="输入你的水鱼 Import-Token"
216224
/>
217225
</n-form-item>
218226

227+
<template v-if="apiMode === 'shuiyu-dev'">
228+
<n-alert v-if="!apiForm.shuiyuDeveloperToken" type="warning" class="mb-4" :show-icon="false">
229+
此站点尚未配置水鱼开发者 Token 环境变量,该模式不可用。
230+
</n-alert>
231+
<n-form-item label="用户名">
232+
<n-input
233+
v-model:value="apiForm.shuiyuUsername"
234+
placeholder="输入目标玩家的用户名"
235+
:disabled="!apiForm.shuiyuDeveloperToken"
236+
/>
237+
</n-form-item>
238+
</template>
219239

220240
<n-button
221241
type="primary"

worker/src/index.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Cloudflare Worker: CORS proxy for diving-fish API
2+
// Only proxies requests to www.diving-fish.com
3+
4+
interface Env {
5+
ALLOWED_ORIGIN: string
6+
}
7+
8+
const ALLOWED_TARGET = 'https://www.diving-fish.com'
9+
10+
function corsHeaders(origin: string): Record<string, string> {
11+
return {
12+
'Access-Control-Allow-Origin': origin,
13+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
14+
'Access-Control-Allow-Headers': 'Content-Type, Import-Token, Developer-Token',
15+
'Access-Control-Max-Age': '86400',
16+
}
17+
}
18+
19+
export default {
20+
async fetch(request: Request, env: Env): Promise<Response> {
21+
const origin = request.headers.get('Origin') || ''
22+
const allowedOrigin = env.ALLOWED_ORIGIN || 'https://munet-oss.github.io'
23+
24+
// Also allow localhost for development
25+
const isAllowed = origin === allowedOrigin
26+
|| origin.startsWith('http://localhost:')
27+
|| origin.startsWith('http://127.0.0.1:')
28+
29+
if (!isAllowed && origin !== '') {
30+
return new Response('Forbidden', { status: 403 })
31+
}
32+
33+
const responseOrigin = origin || allowedOrigin
34+
35+
// Handle preflight
36+
if (request.method === 'OPTIONS') {
37+
return new Response(null, {
38+
status: 204,
39+
headers: corsHeaders(responseOrigin),
40+
})
41+
}
42+
43+
// Extract target URL from query parameter
44+
const url = new URL(request.url)
45+
const targetUrl = url.searchParams.get('url')
46+
47+
if (!targetUrl) {
48+
return new Response(JSON.stringify({ error: 'Missing ?url= parameter' }), {
49+
status: 400,
50+
headers: { ...corsHeaders(responseOrigin), 'Content-Type': 'application/json' },
51+
})
52+
}
53+
54+
// Only allow proxying to diving-fish
55+
if (!targetUrl.startsWith(ALLOWED_TARGET)) {
56+
return new Response(JSON.stringify({ error: 'Only diving-fish.com is allowed' }), {
57+
status: 403,
58+
headers: { ...corsHeaders(responseOrigin), 'Content-Type': 'application/json' },
59+
})
60+
}
61+
62+
// Forward the request, preserving auth headers
63+
const proxyHeaders = new Headers()
64+
const forwardHeaders = ['content-type', 'import-token', 'developer-token']
65+
for (const name of forwardHeaders) {
66+
const value = request.headers.get(name)
67+
if (value) proxyHeaders.set(name, value)
68+
}
69+
70+
const proxyResponse = await fetch(targetUrl, {
71+
method: request.method,
72+
headers: proxyHeaders,
73+
body: request.method !== 'GET' ? request.body : undefined,
74+
})
75+
76+
// Return response with CORS headers
77+
const responseHeaders = new Headers(proxyResponse.headers)
78+
for (const [key, value] of Object.entries(corsHeaders(responseOrigin))) {
79+
responseHeaders.set(key, value)
80+
}
81+
82+
return new Response(proxyResponse.body, {
83+
status: proxyResponse.status,
84+
headers: responseHeaders,
85+
})
86+
},
87+
}

worker/wrangler.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
name = "chunithm-cors-proxy"
2+
main = "src/index.ts"
3+
compatibility_date = "2024-01-01"
4+
5+
[vars]
6+
ALLOWED_ORIGIN = "https://munet-oss.github.io"

0 commit comments

Comments
 (0)