This article was co-authored with a generative AI. Facts have been cross-checked against official documentation where possible, but errors may remain. Please verify primary sources before making any important decisions.

Chouseisan (調整さん) is a popular Japanese scheduling tool — but it has no public API, and entering attendance for events with 40+ candidate slots gets tedious. So I built a small CLI that automates the entry with Playwright. The decision part — figuring out which slots are / / × based on my Google Calendar — is delegated to Claude Code via the claude.ai Google Calendar MCP.

Source: https://github.com/nakamura196/choseisan

TL;DR

  • Chouseisan exposes no public API, so this tool drives the public page DOM directly with Playwright.
  • The workflow is split into three stages: fetch extracts candidate slots, Claude (in chat) fills //× against Google Calendar, and submit sends the form.
  • Decision rules (skip events prefixed 参考:, treat transparency: transparent as free, block opaque all-day events, etc.) live in CLAUDE.md so Claude Code automatically loads them when working in the project directory.
  • Re-running submit with the same name triggers update mode (clicking the existing participant’s link) instead of creating a duplicate row.
  • wait_for_load_state("networkidle") never settles on Chouseisan pages because of ad scripts; use "load" instead.

Architecture

The three stages are independently re-runnable.

fetch <url>      → events/<slug>.json template
(ask Claude)     fill answers by matching Google Calendar
submit <slug>    → Playwright sends the form (auto-detects new vs update)

If candidate slots change, re-run fetch only. If you want to try different judgment criteria, re-ask Claude. Once you’re happy with the JSON, submit.

PathRole
cli.pySubcommands: fetch / list / submit
CLAUDE.mdCalendar-to-attendance rules (loaded automatically by Claude Code)
events/<slug>.jsonPer-event config and answers (gitignored)
.envDefault name for fetch (gitignored)

Observing Chouseisan’s DOM

With no public API, the first job was to inspect the form. Dumping the page after clicking the “出欠を入力する” (Enter attendance) button surfaced the relevant elements:

<input type="button" id="add_btn" class="add-button-input" value="出欠を入力する">

<input id="f_name" class="form-input" maxlength="50" name="name" aria-label="名前">

<!-- per candidate row: hidden input + 3 image buttons -->
<input type="hidden" name="kouho1" id="kouho1" value="3">
<input type="image" class="oax oax-0" id="oax_0_0" alt="...maru">
<input type="image" class="oax oax-1" id="oax_0_1" alt="...sankaku">
<input type="image" class="oax oax-2 active" id="oax_0_2" alt="...batsu" aria-pressed="true">

<input class="form-input hitokoto-input" name="hitokoto" type="text">
<input type="submit" name="add" id="memUpdBtn" value="入力する">

Three things worth noting:

  1. Each row’s value lives in a hidden input kouho{N} (1=◯, 2=△, 3=×, default 3).
  2. The clickable surface is an <input type="image"> with class oax-{0,1,2}. oax-0 is “maru” (◯), oax-1 is “sankaku” (△), oax-2 is “batsu” (×). Clicking syncs the hidden input.
  3. The element IDs oax_0_0 etc. are duplicated across rows in the rendered HTML. So I don’t use them. Instead I use the hidden input’s unique ID as an anchor and grab the image button via the CSS sibling combinator:
# Click the answer column for row N
# col: 0=◯, 1=△, 2=×
btn = page.locator(f"#kouho{idx} ~ input.oax-{col}").first
btn.scroll_into_view_if_needed()
btn.click()

#kouho1 ~ input.oax-0 reads as “any sibling of #kouho1 with class oax-0”. The hidden input and the three image buttons sit in the same <td>, so this resolves to a unique element.

CLI

Three subcommands, that’s it:

# fetch: extract candidate dates and create events/<slug>.json
python cli.py fetch https://chouseisan.com/s?h=<hash>

# list: show registered events
python cli.py list
#   abc123def456  Some meeting   43 slots  ◯18 △2 ×23  [Your Name]

# submit: read events/<slug>.json and submit
python cli.py submit <slug> --dry-run    # fill the form but don't submit
python cli.py submit <slug>              # actually submit
python cli.py submit <slug> --debug      # headful + page.pause() for DOM checks

fetch produces this JSON:

{
  "slug": "abc123def456",
  "label": "Some meeting",
  "url": "https://chouseisan.com/s?h=...",
  "name": "Your Name",
  "comment": "",
  "choices": [
    { "date": "5/7(Thu) 10:00〜11:00", "answer": "×" },
    { "date": "5/7(Thu) 11:00〜12:00", "answer": "×" }
  ]
}

All answers default to ×. The next stage replaces them.

Hooking up Google Calendar via MCP

I considered two approaches for the AI-driven fill step: bake an Anthropic SDK call into the CLI, or just ask Claude Code in conversation. I went with the latter because Claude.ai’s Google Calendar MCP makes the OAuth side trivial — you authorize once via /mcp in Claude Code and from then on, calendar tools (list_events, list_calendars, etc.) are available directly in chat.

The actual prompt I send is just:

Fill b03_2026’s schedule by checking against Google Calendar.

Claude reads the candidate slots in events/b03_2026.json, calls list_events for the relevant date range, and rewrites each answer based on overlaps. The rationale gets shown alongside the result (e.g. “5/14 morning slots → × because faculty meeting is set as all-day”), and I can push back with “actually 5/14 mornings are fine, the meeting is afternoon-only” before submitting.

Decision rules in CLAUDE.md

Calendar conventions are personal. Mine include:

  • Events whose summary starts with 参考: are reference info, not actual time blocks.
  • Events whose summary starts with 終日: are explicit all-day labels (still blocking unless transparent).
  • transparency: transparent events are “free” — Google Calendar treats them as available, and so do I.
  • All-day events without those signals fully block the day.

Putting these in CLAUDE.md at the project root means Claude Code loads them automatically every time I work in the directory. I don’t have to re-explain my conventions per session.

### Events to skip

Skip (don't treat as blocking) if `summary` starts with:

- `参考:` … reference info, doesn't actually consume time

### Events that block

- Timed events (`dateTime` present)
- All-day events (`date` only) with `transparency``transparent`
- Events whose `summary` starts with `終日:`

### Answer mapping

| Situation | answer |
|---|---|
| No event overlapping the candidate slot | `◯` |
| Adjacent event with zero buffer | `△` |
| Event overlapping the candidate slot | `×` |
| Day blocked by an all-day event | `×` |

A side benefit of having rules in a separate file: when a judgment looks off, it’s easy to tell whether the rule was wrong vs Claude misapplied a correct rule.

Update mode

Chouseisan’s UI says: “To change your attendance, click your name link.” So when an existing user re-edits, the form is opened by clicking the participant’s name in the list, not by #add_btn. To keep submit idempotent and avoid creating duplicate rows on each run, I added a check at page-load time: if a link with the user’s exact name exists, click it (update mode); otherwise click #add_btn (new entry).

def _existing_entry_link(page, name):
    link = page.get_by_role("link", name=name, exact=True)
    return link.first if link.count() > 0 else None


def _open_form(page, name):
    link = _existing_entry_link(page, name)
    if link is not None:
        link.click()
        page.wait_for_selector("#f_name", state="visible", timeout=5000)
        return "update"
    page.locator("#add_btn").click()
    page.wait_for_selector("#f_name", state="visible", timeout=5000)
    return "new"

The mode is logged at runtime:

Event: B03 2026 First Meeting
Name: Your Name  (mode: update)
  [ 1/43] 5/7(Thu) 10:00〜11:00 → ◯
  ...

Things that bit me

networkidle never settles

I initially used page.wait_for_load_state("networkidle") after submit, but Chouseisan’s pages embed many third-party ad/analytics tags that keep the network active well past the actual form submission. The 30-second default was hitting timeout consistently. Switching to "load" is sufficient — the form post itself is done by then.

page.wait_for_load_state("load", timeout=15000)

The first time I hit this, the script raised a timeout but the entry had actually been registered. False negative on the client side.

Duplicate oax_0_0 IDs across rows

I expected the image button IDs to encode the row index (e.g. oax_5_0 for row 5, column 0), but in the rendered HTML every row had oax_0_0, oax_0_1, oax_0_2. Playwright’s locator("#oax_5_0") would hit a strict-mode violation. The #kouho{N} hidden inputs do have row-specific IDs, so I anchor on those and use the sibling combinator (~) to reach the image buttons — robust against the duplicate-ID bug.

Wrap-up

  • Chouseisan’s frontend is Vue-based, but the form itself is a plain HTML form that posts to a server endpoint. That makes Playwright automation fairly straightforward.
  • Delegating the AI judgment step to “ask Claude in chat” lets me skip Anthropic SDK setup and OAuth credential management for Google Calendar. The MCP integration in Claude Code makes this surprisingly low-friction.
  • Putting decision rules in CLAUDE.md lets per-user calendar conventions (prefix naming, all-day semantics) live as version-controlled context outside the script.

Source: https://github.com/nakamura196/choseisan

Disclaimers

  • Chouseisan’s DOM may change without notice. The selectors here reflect what was observed in April 2026.
  • Chouseisan is a personal scheduling tool. This automation is intended for “save myself the typing of one response”, not bulk submission or scraping.
  • This is an unofficial personal project, not affiliated with Mixtend Inc. (the company behind Chouseisan).

References