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

Suifeng0214 Lv3

背景

最近打桌球積分賽,有球館自行開發線上登記成績與即時排名系統,我因為好奇就問了他們怎麼處理這些資料,說不定能幫忙弄上 TAIN 平台(因為比賽是全循環,所以需要登記的場次數量會蠻大),聊完以後,對方也感興趣就開始了這次的合作~

我原本想先測試能不能在 F12 Console 就完成,因為之前有些簡單的操作可以靠 document.getElementBy… 就完成了。
不過這次的 DOM 是動態變化的(如簡易模式需要先 click 輸入成績後才會跳 dialog 出來),我背景知識可能不足,一時弄不出來。
GPT 有說可以用 await waitForSelector('#gameResultTab-tab-2') 的語法等待,
不過我想了想,這次還是希望能試試用 Playwright 完成任務,就鬼轉 Playwright 了。

waitForSelector by.GPT

1
2
3
4
5
6
7
8
9
async function waitForSelector(selector, timeout = 5000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const el = document.querySelector(selector);
if (el) return el;
await new Promise(r => setTimeout(r, 100));
}
throw new Error(`Timeout waiting for selector: ${selector}`);
}

附上一開始寫一半的 Javascript

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
const matchSize = document.getElementsByClassName("js-input-status-text small text-danger ms-3")[0].innerText.split('/')[1];

for (let i = 0; i < 10; i++){
document.getElementsByTagName("button")[i].click(); // 輸入成績
await new Promise(r => setTimeout(r, 100));
const p1 = document.getElementsByClassName("js-player-data")[i*2].innerText;
const p2 = document.getElementsByClassName("js-player-data")[i*2+1].innerText;

const matchResult = results.find(r =>
(r.p1 === p1 && r.p2 === p2) ||
(r.p1 === p2 && r.p2 === p1)
);
if (!matchResult) {
console.log("找不到場次成績:", i+1);
continue;
}

document.querySelector('#gameResultTab-tab-2').click(); // 簡易模式

document.querySelector('input.form-control.select-input.form-control-lg.placeholder-active.active').click(); // 打開下拉選單

const options = [...document.querySelectorAll('.select-option-text')];
const selected_score = options.find(o =>
o.textContent.trim() === matchResult.score);
if (selected_score) selected_score.click();

document.getElementsByClassName("btn btn-primary shadow-0 js-quick-score-input-save ripple-surface")[0].click() // 儲存
}

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
    38
    import 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
    99
    import { 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 });
    }
    }
    });

執行過程

  1. pnpm create playwright (一直 Enter 選預設)
  2. 再來把 .test.ts 腳本與 results.json 放至 \tests\ 目錄底下
  3. 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
    2
    const nodeList = document.querySelectorAll('div');  // NodeList
    const arr = [...nodeList];
On this page
TAIN 自動登錄賽事成績 | Playwright 瀏覽器自動化工具