TAIN 自動登錄賽事成績 | Playwright 瀏覽器自動化工具

背景
最近打桌球積分賽,有球館自行開發線上登記成績與即時排名系統,我因為好奇就問了他們怎麼處理這些資料,說不定能幫忙弄上 TAIN 平台(因為比賽是全循環,所以需要登記的場次數量會蠻大),聊完以後,對方也感興趣就開始了這次的合作~
我原本想先測試能不能在 F12 Console 就完成,因為之前有些簡單的操作可以靠 document.getElementBy… 就完成了。
不過這次的 DOM 是動態變化的(如簡易模式需要先 click 輸入成績後才會跳 dialog 出來),我背景知識可能不足,一時弄不出來。
GPT 有說可以用 await waitForSelector('#gameResultTab-tab-2')
的語法等待,
不過我想了想,這次還是希望能試試用 Playwright 完成任務,就鬼轉 Playwright 了。
waitForSelector by.GPT
1 | async function waitForSelector(selector, timeout = 5000) { |
附上一開始寫一半的 Javascript
1 | const matchSize = document.getElementsByClassName("js-input-status-text small text-danger ms-3")[0].innerText.split('/')[1]; |
Playwright 開發過程
首先,我是用 pnpm : pnpm create playwright
我選 typescript,並且因為這個專案不需要 CI,所以 Github Actions 選 false
最終成功運行 Source code
- convert.py [by ChatGPT]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38import re
import json
def process_input(text, output_file="results.json"):
lines = text.strip().split('\n')
results = []
print("輸出整理後比賽結果:")
for line in lines:
parts = re.split(r'\t+|\s{2,}', line.strip())
if len(parts) < 3:
continue
p1 = re.sub(r'^\d+\s*', '', parts[0])
p2 = re.sub(r'^\d+\s*', '', parts[1])
score = parts[2].strip()
score_display = score.replace(':', ' : ')
print(f"{p1}\t{p2}\t{score_display}")
results.append({'p1': p1, 'p2': p2, 'score': score_display})
print("\n已寫入 results.json,內容如下:")
print(json.dumps(results, ensure_ascii=False, indent=2))
# 寫入合法 JSON
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=2)
def read_multiline_input():
print("請貼上多行比賽成績,輸入空行後按 Enter 結束:")
lines = []
while True:
line = input()
if line.strip() == "":
break
lines.append(line)
return "\n".join(lines)
if __name__ == "__main__":
user_input = read_multiline_input()
process_input(user_input) - 把
results.json
準備好,放在跟腳本相同目錄下1
2
3
4
5
6
7
8
9
10
11
12[
{
"p1": "選手一",
"p2": "選手二",
"score": "2 : 0"
},
{
"p1": "選手二",
"p2": "選手三",
"score": "2 : 0"
}
] test.setTimeout(400000);
很重要,代表這個測試的最大 Timeout 時間(單位: ms),預設好像 30 秒1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99import { test, expect, chromium } from '@playwright/test';
import fs from 'fs';
import path from 'path';
test.setTimeout(400000); // 重要
// 讀入外部 JSON
const results = JSON.parse(
fs.readFileSync(path.resolve(__dirname, 'results.json'), 'utf-8')
);
function reverseScore(score: string): string {
const [a, b] = score.split(/\s*:\s*/);
return `${b} : ${a}`;
}
function findResult(p1: string, p2: string) {
const record = results.find(r =>
(r.p1 === p1 && r.p2 === p2) ||
(r.p1 === p2 && r.p2 === p1)
);
if (!record) return undefined; // 沒對應資料
const score =
record.p1 === p1 ? // 順序相同 → 直接用
record.score :
reverseScore(record.score); // 順序相反 → 反轉比分
return { p1, p2, score };
}
test('批次自動輸入比分', async ({ page }) => {
await page.goto('https://tain.tw/co-editor/e9bd45d8-20e3-4745-95c0-8e8580671b70/match/0');
await page.route('**/*adsbygoogle*', route => route.abort());
await page.route('**/pagead/js/adsbygoogle.js*', route => route.abort());
await page.route('https://*.googlesyndication.com/**', route => route.abort());
await page.addStyleTag({
content: `
*,*::before,*::after {
transition: none !important;
animation: none !important;
}
html { scroll-behavior: auto !important; }
`,
});
const matches = page.locator('.col-12.col-lg-6');
const matchCount = await matches.count();
const buttons = page.locator('button');
console.log('總共 ', matchCount, ' 場次');
for (let i = 0; i < matchCount; i++) {
const modal = page.locator('#scoreInputModal');
console.log('第 ',i+1, ' 場次');
if (await modal.getByRole('button', {name:'Close'}).isVisible())
await modal.getByRole('button', {name:'Close'}).click({ force: true });
const players = matches.locator('.js-player-data');
const p1 = (await players.nth(2*i).innerText()).trim();
const p2 = (await players.nth(2*i+1).innerText()).trim();
console.log('p1:', p1, 'p2:', p2);
const matchResult = findResult(p1, p2);
await buttons.nth(i).click({ force: true });
await modal.waitFor({ state: 'visible', timeout: 5000 });
if (!matchResult) {
await modal.getByRole('tab', { name: '標準' }).click({ force: true });
const tab = modal.getByLabel('標準');
console.log('停賽');
if (await tab.getByText('VS').isVisible()) {
const status = await tab.getByText('VS');
if (await status.isVisible())
await status.click({ force: true });
} else if (await tab.getByText('停賽').isVisible()) {
continue;
}
if (await modal.getByRole('button', { name: '儲存' }).isEnabled())
await modal.getByRole('button', { name: '儲存' }).click({ force: true });
continue;
}
console.log('result: ', matchResult.score);
await modal.getByRole('tab', { name: '簡易' }).click({ force: true });
if (await modal.getByRole('listbox').first().isVisible())
await modal.getByRole('listbox').first().click({ force: true });
if (await page.getByRole('option', { name : matchResult.score }).isVisible())
await page.getByRole('option', { name : matchResult.score } ).click({ force: true });
try {
await modal.getByRole('button', { name: '儲存' }).click({ force: true });
await modal.waitFor({ state: 'hidden', timeout: 2000 });
} catch (error){
await modal.getByRole('button', { name: '儲存' }).click({ force: true });
await modal.waitFor({ state: 'hidden', timeout: 2500 });
}
}
});
執行過程
pnpm create playwright (一直 Enter 選預設)
- 再來把 .test.ts 腳本與
results.json
放至 \tests\ 目錄底下 pnpm exec playwright test --project chromium --ui
對 批次自動輸入比分 按 Run
- 如果 403 Error 代表主辦人未開放共同輸入比分
- 如果 --ui 順利執行,可以嘗試不用 --ui ,直接跑
pnpm exec playwright test
看 Console - 最好指定一種瀏覽器,不然預設會執行三種。
加上--project chromium
或 編輯playwright.config.ts
設定檔內的 projects: []
透過這個 Project 學習到的知識
自訂下拉選單
1 | <input class="form-control select-input form-control-lg placeholder-active active focused" type="text" role="listbox" aria-multiselectable="false" aria-disabled="false" aria-haspopup="true" aria-expanded="false" readonly=""> |
這個 <input>
不是傳統 <select>
元素,而是屬於自訂下拉選單(custom dropdown)的一部分,通常用 JavaScript 和 ARIA 屬性模擬 <select>
功能。
- class 有 select-input、active、focused 等,通常是第三方 UI framework 的自訂下拉元件。
Javascript 賦值 let VS var
querySelector
- document.querySelectorAll 是瀏覽器中用來選取符合 CSS 選擇器條件的 所有 元素,回傳的是一個 NodeList(類陣列物件)。
展開運算子
- … 是 JavaScript 的展開運算子(spread operator),用來把一個「可迭代物件」展開成一個「陣列元素」。
1
2const nodeList = document.querySelectorAll('div'); // NodeList
const arr = [...nodeList];