| name | engineer |
| description | Implementation rules for database migrations, Python scrapers, and Next.js web for the Engineer agent |
| applyTo | .github/agents/engineer.agent.md |
Engineer Skills
Read this at the start of every session before touching any code.
⚠️ CRITICAL: Canonical File Paths
NEVER write to .github/skills/agents/engineer/ — that path has been deleted. The canonical location is:
.github/skills/agents/engineer/SKILL.md and .github/skills/agents/engineer/history.md
Same rule applies to ALL agent skills:
| Agent | Canonical path |
|---|
| engineer | .github/skills/agents/engineer/ |
| researcher | .github/skills/agents/researcher/ |
| scraper-expert | .github/skills/agents/scraper-expert/ |
| scraper-dev | .github/skills/agents/scraper-dev/ |
| architect | .github/skills/agents/architect/ |
| tester | .github/skills/agents/tester/ |
Writing to a top-level skills/<name>/ path recreates deleted directories. Always use skills/agents/<name>/.
Workflow Automation Guardrails
- For alert-driven automation, split into two stages:
triage first, auto-fix second. Never mix high-risk remediation into the triage stage.
- Keep high-risk classes (for example selector drift or source-structure breakage) in human-review only paths; safe auto-fix should only touch deterministic transforms.
- Every LINE alert message must include a direct next action (exact workflow name + trigger mode). A warning without CTA is operational noise.
- For publication-related pending QA, clean by source and status transition first. If a source is mixed-content like
eslite_spectrum, keep the rule set conservative and do not fold promotional talk events into the same batch.
- Pure publication classification is exact-only: use normalized
event_form == ['publication']; never infer pure from books_media, source name, or title prefix.
- Pure publication rows keep seven intentional-null fields plus empty sentinels in lockstep:
location_address, location_address_zh, location_address_en, business_hours, business_hours_zh, business_hours_en, location_prefectures.
- Preserve real DB prices (
is_paid, price_info, price_amount); hide pure-publication prices only in UI and JSON-LD. Price fields, location_name, and location_url are outside the seven-field NULL/clear policy.
- Publisher (
organizer) remains required for pure publication rows; missing publisher stays a QA finding.
- Mixed rows (for example
['publication', 'lecture']) are physical events and must not inherit pure skip behavior.
- Venue homepage repairs (
location_url) are provenance-sensitive. The only safe auto-fix target is the venue's own official homepage; never promote source_url, official_url, or organizer_url into location_url just because a search hit looks plausible.
- If a venue cannot be verified to have its own homepage, leave the report pending for human review. Shared municipal spaces and parent-organization pages are especially prone to false positives.
- For pending QA cleanup, use
qa_heartbeat.py as the real dispatch entry. qa_auto_fix.py CLI only runs its own built-in maintenance batches (simplified Chinese + tokyoartbeat date sync); do not assume it will process the full safe-report backlog.
- Database-only QA cleanup does not require git push or deployment. Only change code or docs when the cleanup logic itself needs to be adjusted.
- For publication metadata backfills, keep the helper logic source-scoped: NDL OpenSearch periodical rows need breadcrumb-derived labels and publisher-backed organizer, while non-periodical publication rows may keep the generic publication placeholder.
- For publication metadata backfills, also prefer the official product page description when
official_url is present, and prefix publication titles with [新刊出版] across locale variants. NDL magazine and journal rows should use bracketed periodical labels like [期刊專文].
- Publication event-form sync includes writer whitelist: keep
scraper/database.py::_VALID_EVENT_FORMS and its tests updated whenever publication handling changes.
Agent Handoff Reliability
- For workflow agents, treat handoffs as a required output path, not optional UI polish.
- Do NOT set
send: true on handoffs. Since 2026-05-14 VS Code auto-fires send: true prompts, blocking the user from reviewing or pushing first. Omit it so the prompt: lands in the input box for user confirmation.
- Pre-ship check for agent changes: verify target agent names resolve correctly and at least one post-task handoff exists for the expected next action.
- Every new agent file must include
handoffs: at creation time. An agent with no handoffs leaves users stranded after task completion.
CSS Selector Specificity Pitfalls
[attr='x'].class = same element (no space). [attr='x'] .class = descendant combinator (space = different element).
- When combining an attribute selector with a class on the same element (e.g.,
data-preserve-theme and group on the same <Link>), never insert a space between them.
- For theme-exception rules like
[data-preserve-theme='light'].group:hover h2, verify: (1) the attribute and the class are on the same DOM element, (2) combined specificity beats any competing rule, (3) use getComputedStyle via Playwright to confirm hover color changes.
Cross-browser Baseline Checks
- When a UI change touches shared pills, buttons, tabs, or dropdown triggers, verify the result in both Chrome and Safari before broadening the fix.
- If Safari alone looks vertically off, prefer the smallest browser-specific baseline correction over reworking every related component.
- Recheck computed
line-height, padding, and element height in Safari after any shared inline-flex / rounded-full refactor.
- Safari WebKit #169700 —
flex on <button> mis-calculates height. Never put flex items-center justify-center gap-3 directly on a <button> className. Wrap the content in an inner <span className="flex items-center justify-center gap-3"> instead. The <button> keeps only padding / border / background and remains at its native block height in both Chrome and Safari.
Click Hit-Area Integrity
- Overriding a shared
Button's padding with px-0 py-0 shrinks the clickable hit area down to the text glyphs — the element looks present but is effectively unclickable in the padding zone.
- To visually align a button flush with its container edge, offset with a negative margin (e.g.
-ml-4) instead of zeroing the padding. Keep the component's native px-4 py-2 hit area.
- In a
flex justify-between row, always give the adjacent heading truncate and give the interactive element shrink-0. A long (often English) title can otherwise overlap and steal a low-padding sibling's hit area.
API Route Error Surfacing
- "Success" for an API route is NOT "did not throw." Every external call (LLM, DB write, third-party fetch) must explicitly check
res.ok / error and return a non-2xx status with a readable detail.
- Anti-pattern that hides root cause for days:
if (extRes.ok) { ... } with no else, empty catch {} around JSON.parse, and unchecked await supabase...update() — then returning 200 { success: true } regardless. The frontend's t(res.error) then has nothing to show and only the generic saveFailed appears.
- When adding a new backend error code, surface it end-to-end: non-2xx +
{ error, detail } on the server, append detail to the frontend message, and add the i18n key to all three messages/*.json simultaneously.
Navigation Action Pattern
- Nav click handlers must NOT do async DB pre-flight to decide the routing target. "Fetch-then-route" binds DB latency to click feedback and makes the action fragile if the fetch fails or times out.
- Let the destination page handle conditional redirects (e.g. profile-required check). The nav only pushes the canonical URL; the page reads its own state and redirects if needed.
- Remove any
loading state that was only there to cover the pre-flight gap — users should see an immediate page transition, not a loading spinner inside the nav dropdown.
Client-only tab/filter switching(避免 RSC round-trip;2026-07-04 教訓)
- 頁面資料已全在 client props 時,tab/filter 這類純顯示切換別用 URL param +
router.replace() 驅動。App Router 中任何 URL 變更(即使只改 ?tab= query)都會重跑該 route 的 server component、重發其全部 Supabase 查詢、重序列化整個 RSC payload —— 即使資料沒變。account 頁切換「收藏」/「我的活動」曾因此每次 round-trip 重跑 5 個查詢(auth/creators/saved_events join/owned events/parents,54bb040, 2026-07-04)。
- 正解=
useState + window.history.replaceState:const [tab, setTab] = useState(searchParams.get("tab") ?? default)(初始讀 URL 供 deep link/重整保留),切換時 setTab(x) + window.history.replaceState(null, "", url) 同步 URL 但不觸發 Next 導航。兼得即時切換、deep link、零 server fetch。
- 反例辨識:切 tab/filter 有可感延遲、且每次切換都重打 RSC/DB → 就是 URL-driven state 的 round-trip,改成 client state。
E2E Local Server Prerequisite
- When running Playwright smoke tests that navigate to local paths (for example
/ja/announcements), start a local Next server first (npm run dev or configured webServer) and verify port 3000 is reachable.
- Treat
net::ERR_CONNECTION_REFUSED as an execution-environment failure first, not an application regression.
- After the test run, stop any background dev server to avoid orphaned processes and cross-session interference.
- worktree localhost = frozen base. A linked worktree freezes its branch base at branch-creation time; main keeps advancing. Before previewing a feature in a worktree dev server, run
git fetch origin && git rebase origin/main so localhost shows the real latest design, not a behind-by-N snapshot. A feature can't be tested on main until merged — after rebase the worktree equals main + feature, so test there to avoid two-location switching.
- merge commit may trip the remote secret hook when ff doesn't. A
--no-ff merge has two parents, so the remote pre-receive scans full-reachable history and can flag an old blob the local .gitleaks.toml allowlists; a linear ff push scans only the increment and passes. This is NOT a safe bypass — after the ff succeeds, run gitleaks dir . on the whole tree to confirm 0 real secrets. Fix path: reset main, rebase the worktree onto latest main into one linear commit, git push origin HEAD:main. Never use --no-verify.
- Worktree operating = canonical + tasks.md. 大型 feature 在
ttr-<slug>-worktree 隔離開發;所有 git worktree 命令、state matrix、STOP 條件見 .github/instructions/git.instructions.md § Isolated worktree(單一權威)。建立後 idempotent grep -qxF 'ttr-<slug>-worktree/' .git/info/exclude || echo 'ttr-<slug>-worktree/' >> .git/info/exclude。rebase/preview 前 worktree 必須 clean,絕不 git stash(repo-wide stash 會重新引入並行踩踏)。docs/specs/active/<slug>/tasks.md 為跨 session 進度真值,逐步打勾 commit。merge-back 交 V-M-D(linear ff,見上一條 gitleaks note)。
- Fixed-width device preview frames must use responsive constraints such as
w-full max-w-[390px]; do not set width: 390px inside padded mobile pages. In visual smoke tests, assert document scrollWidth <= clientWidth and distinguish expected horizontal carousels from real page overflow.
Terminal Mojibake / Locale(macOS;agent 讀 terminal 亂碼;2026-07-03 教訓)
- If agent-read
run_in_terminal output shows intermittent CJK mojibake (esp. multi-command chains or git log with Chinese/Japanese commit messages), suspect locale first, not shell-integration markers. Diagnose by contrasting a pure-ASCII multi-command run (stays clean) vs a CJK-containing run (garbles).
- macOS BSD libc does NOT ship
C.UTF-8. locale only echoes the value — confirm real support with locale -a | grep -i utf (macOS lists en_US.UTF-8, zh_*.UTF-8, never C.UTF-8). LANG=C.UTF-8 means setlocale degraded to POSIX/C byte-mode → CJK char-width miscalc → a terminal wrap or OSC-633 marker splits a multi-byte char → half char = mojibake.
- Source:
/etc/zprofile if [ -z "$LANG" ]; then export LANG=C.UTF-8; fi, triggered when VS Code launches from Dock/Finder (does not inherit LANG).
- Fix:
export LANG=en_US.UTF-8 in ~/.zshenv (loads before /etc/zprofile, so the fallback never fires) + "terminal.integrated.env.osx": { "LANG": "en_US.UTF-8" } in VS Code settings. Verify: env -i HOME="$HOME" PATH="$PATH" TERM=xterm-256color /bin/zsh -lic 'echo $LANG' must print en_US.UTF-8. Existing terminals need Reload Window / new terminal.
- Python tolerates
C.UTF-8 (PEP 540) so python-only checks look clean — test BSD tools instead.
- Locale is only an AMPLIFIER; the main intermittent cause is VS Code shell-integration flakiness (
$PATH blanks mid-command). Workarounds still needed: redirect output to a file then read_file, pure-ASCII output via absolute-path /bin/bash script, or Reload Window to clear poisoning.
Commit & Push State Awareness(防「no changes added」誤判;2026-07-03 教訓)
- Commit/push 前先
git status -sb 分類本地狀態,別對空 index 重跑 git add/git commit:
ahead N, working tree clean(無 M/??)→ 變更已提交,跳過 commit 直接 git push。此時的「nothing to commit / no changes added」是誤判非失敗。
- 有
M/??(modified/untracked) → 照常 stage + commit。
ahead 0, working tree clean 且 origin 無新 commit → 無待推送變更,無事可做。
- V-M-D 反覆卡「no changes added」的根因=把「前一輪已 commit(ahead+clean)」誤當「還要再 commit」,於是對空 index 空轉。唯一待辦是 push,不是再 commit,更不是部署失敗。
Secret Scanning — Three-Layer Defense(fail-open;2026-06-28 教訓)
新增/修改密鑰掃描時三層必須一致,且都是「降低風險」非 non-bypassable(真正強制需 server-side branch protection):
| Layer | 位置 | 行為 |
|---|
| 1 | .githooks/pre-commit | gitleaks git --staged -c .gitleaks.toml;缺 binary → 警告 + exit 0(fail-open,不擋本機 commit) |
| 2 | .githooks/pre-push | 有 gitleaks → 掃 push range;無 → regex fallback 擋 github_pat_…{20,} / sk-… / eyJ… JWT |
| 3 | .github/workflows/secret-scan.yml | CI merge gate;version-pinned + checksum-verified gitleaks binary(非 gitleaks-action,免 org license);permissions: contents: read |
- regex fallback 必含 placeholder allowlist:shell fallback 不讀
.gitleaks.toml。tracked docs 的具名 placeholder(github_pat_REPLACE_WITH_YOUR_TOKEN、github_pat_<NEW_TOKEN_VALUE> 等)長度超過 {20,},裸 fallback 會誤判為真 secret → 命中後先 grep -vE '<placeholder>' 過濾再判定。三份 allowlist(.gitleaks.toml、pre-push fallback、Hygiene git grep)同一份語意,改一處要三處同步。
- Public Repo Secret Hygiene:掃 tracked 檔用
gitleaks git --redact --no-banner -c .gitleaks.toml(命中數須為 0;allowlist-aware + redacted)。禁止 repo-root grep -rn——會讀進被 .gitignore 忽略的 scraper/.env,把真 PAT 印到 terminal / transcript / CI log。git check-ignore -v scraper/.env 須命中、git ls-files --error-unmatch scraper/.env 須失敗。
- token docs 存在性檢查改 masked:任何文件不得叫人
grep "^GITHUB_TOKEN=" .env(會印 token 行);改 awk -F= '/^GITHUB_TOKEN=/{print "present, len="length($2)", prefix_ok="...}'(length/prefix,不印值)。canonical 來源 docs/GITHUB_TOKEN_SYNC_CHECKLIST.md 與同步副本 .github/instructions/token-rotation.instructions.md 必須同 commit 保持一致。
Admin GPT routes — server-side enum whitelist intersect(2026-05-26 教訓)
Admin OCR/annotate API routes(web/app/api/admin/extract-from-image/route.ts、web/app/api/admin/annotate-event/route.ts)由 GPT 直接輸出 enum 欄位(category[]、event_form、prefecture_code),寫入 DB 前必須做白名單過濾。三道天然防線對此無效:
| 防線 | 為何失效 |
|---|
| TypeScript | 只檢查靜態型別,不檢查 runtime LLM 輸出 |
| DB CHECK constraint | text[] 陣列元素無法 CHECK,只能 CHECK 整個陣列存在性 |
scraper/annotator.py::_validate_categories() | 只有 daily scraper 路徑會跑,admin route 完全繞過 |
規則: 兩個 admin route 在 GPT 回傳後、upsert 前必須:
const VALID_CATEGORIES = new Set([
]);
const sanitized = (parsed.category ?? []).filter((c: string) => VALID_CATEGORIES.has(c));
VALID_CATEGORIES 集合可從 web/lib/types.ts 匯入 CATEGORIES 陣列轉成 Set,避免雙重維護。同理適用 event_form、prefecture_code 等所有 enum 欄位。
Reference incident: 2026-05-26 — event 25e27de9 GPT 自創 photography 入庫,前端顯示 categories.photography raw i18n key(commit 264afed 解了眼前事件,未實作 whitelist filter,列 backlog)。
Shared lib rule(2026-06-05 教訓): 兩條以上 annotate route 做相同 LLM 輸出過濾時,立刻抽到 web/lib/eventFieldMerge.ts(或同目錄的 shared module),不要各自內聯。account/annotate-event 與 admin/annotate-event 原本各自複製 validCategories / validEventForms / VALID_PRIMARY_LANGUAGES,2026-06-05 已統一到 sanitizeCategoryValues()、sanitizeEventFormValues()、sanitizePrimaryLanguageValue()。
Location field overwrite protection: annotate route 不可用 cur === null || cur === "" 的空值檢查來決定是否覆寫 location_name/location_address。必須同時考量:(1) web search score(空值填入 ≥ 3,非空 upgrade-overwrite ≥ 6),(2) 使用者是否明確鎖定欄位(lockedFields),(3) 欄位是否明確來自本次 create/edit session 的 auto-fill provenance(overwriteableFields,通常只包含 OCR 自動填入且尚未被手動改過的欄位)。邏輯已封裝在 shouldApplyAnnotatedLocationField(field, cur, v, { bestScore, lockedFields, overwriteableFields })。缺少 provenance 時,非空欄位必須預設維持 fill-only,不得冒然覆寫。 任何新 annotate route 與對應 create client 都應使用這組 shared contract,而不是各自內聯 overwrite 條件。
Database
- Always verify a migration has been applied in Supabase before writing code that depends on it. Check:
SELECT table_name FROM information_schema.tables WHERE table_name = 'X';
- When adding a DB column, wire up the code that populates it in the same commit. Empty columns = silent data gaps.
- Wrap non-critical DB inserts (logging, analytics) in
try/except. Never let a failed log write break the main pipeline.
supabase/migrations/ must contain only NNN_name.sql files. Never place .md files, smoke-test scripts, validation reports, or any non-migration artifacts in this directory. Supabase CLI and tooling will attempt to run every .sql file in this directory as a migration — a misplaced file will cause schema errors or no-op runs. If a migration agent produces a smoke-test or verification file alongside the SQL, place it anywhere outside supabase/ (e.g. a temp directory or delete it). Example incident: 027_smoke_test.sql, 027_VALIDATION.md, 027_VERIFICATION_REPORT.md were accidentally created in migrations/ by a previous agent and had to be manually deleted (commit chore(migrations): remove non-migration 027 artifacts).
- PostgREST COUNT は必ず
{ count: 'exact', head: true } を使う。 .limit(n) + client-side filter でカウントを計算すると PostgREST の max-rows=1000 silent truncation により 1,000 件超で常に過小表示になる。カウントのみ必要な場合は行データを取得しない head-count クエリを使う:
const { data } = await supabase.from("events").select("id").eq("is_active", true);
const count = data?.length ?? 0;
const { count } = await supabase
.from("events")
.select("id", { count: "exact", head: true })
.eq("is_active", true);
複数の COUNT クエリは Promise.all([...]) で並列実行すること(直列 await は不要)。Reference incident: AEO summary cards capped at 1,000 (commit 518b5a8, 2026-05-15).
- When a logging table gains new
NOT NULL columns (e.g. success, duration_seconds), both the success path and the except block must write those columns explicitly. Pattern from scraper_runs:
{"success": True, "duration_seconds": int(time.time() - start)}
{"success": False, "duration_seconds": 0}
If only the success path is updated, failure rows leave the column NULL and break NOT NULL constraints (or silently insert the default, hiding failures).
- NOT NULL 欄位在 web server-action 的 write 路徑也要對稱保護(insert + 全部 update)。 同上
scraper_runs success/except 對稱規則的 web 版。owner 建活動的 source_url(events NOT NULL):insert 路徑 createOwnerDraft 有 || OWNER_SUBMISSION_SOURCE_FALLBACK + canonical /ja/events/{id} backfill,但 updateOwnerEvent(publish)/updateOwnerDraft 一度直接 .update(sanitizeOwnerForm(form)),而 sanitizeOwnerForm 在使用者未填任何 URL 時給 source_url = ... || null → update 用 null 覆蓋 DB 現有 canonical 值 → null value in column "source_url" ... violates not-null constraint,卡住發佈(cd55e57, 2026-07-03)。
- 修法=update 前從 payload 移除該 key,不要填 fallback:
if (!payload.source_url) { delete payload.source_url; }(讓 update 不動該欄位、保留 canonical;填 fallback 會用較差 URL 覆蓋 /ja/events/{id})。
- 同一 write bug 在 owner/admin 走不同 action,先確認使用者實際路徑:
EventIntakeWizard 以 context="owner"|"admin" 分派 —— owner → owner-events.ts、admin → admin-events.ts(sanitizeForm 用 ?? SITE_URL 永不 null,本就安全)。只改一端=沒改。
- 表單自由文字 input → Postgres
text[] 欄位,write 前必 split 成陣列。 AdminEventForm 的 co_organizers/sponsors 是純 text <input>(值型別雖標 string[] | null,實際綁逗號字串),DB 欄位卻是 text[];直接寫字串 → malformed array literal: "A, B, C",阻斷 owner 發佈(f080fd5, 2026-07-04)。
- 修法=write 前
.split(/[,,、]/).map(s => s.trim()).filter(Boolean)(含全形逗號 ,、頓號 、,貼合日文輸入),空陣列 → null。owner 端封裝為 owner-events.ts::parseCommaArray + COMMA_SEPARATED_ARRAY_FIELDS。
- owner/admin 對齊(同上 source_url 規則):admin(
AdminEditClient 提交前已 .split(","))本就對,owner(sanitizeOwnerForm)漏了 → 只在 owner UGC 觸發。改任一端欄位正規化,grep 另一端是否對稱。
- 同一欄位所有寫入面都要 split:
events 表 + field_corrections(recordFieldCorrections)。後者若存原始字串,未來 annotator 套回 text[] 會再度 malformed,且字串 vs 陣列比較每次誤判為「變更」而灌 FC。凡「表單字串 → 陣列欄位」修復,events 寫入 + FC 記錄兩面一起改。
- Every migration that creates a new table MUST include explicit
GRANT statements (Supabase policy change, effective October 30, 2026). Without them, PostgREST/supabase-js returns 42501 permission error silently. Migration 069_explicit_grants.sql retroactively covers all pre-existing tables. Use the tier template in .github/instructions/database.instructions.md §GRANT template:
- Tier A (public-read):
GRANT SELECT ON ... TO anon, authenticated, service_role;
- Tier B (admin-only):
GRANT SELECT, INSERT, UPDATE, DELETE ON ... TO authenticated, service_role;
- Tier C (service-role only):
GRANT SELECT, INSERT, UPDATE, DELETE ON ... TO service_role;
Always ALTER TABLE ... ENABLE ROW LEVEL SECURITY before adding GRANTs.
- GRANT scope を決める前に「誰が書くか」を Python コードから逆引きする。 RLS ポリシーのみ参照すると書き込み GRANT が漏れる。典型的な漏れパターン:
scraper_runs・research_reports は scraper/annotator が service_role で INSERT するため service_role INSERT が必要。creators は admin UI から authenticated が CRUD するため authenticated CRUD が必要。
- Admin が UPDATE/DELETE するテーブルには SELECT だけでなく全 DML の RLS policy を migration 時に一括作成する。
CREATE TABLE 時に SELECT policy のみ追加し UPDATE policy を漏らすと、admin UI からの更新が PostgREST により 0-row silent success として返り、JS 側では error: null なので原因特定が困難になる。チェックリスト(admin 操作テーブル):SELECT / INSERT / UPDATE / DELETE の 4 種 policy を全て作成したか確認すること。参照インシデント:research_reports が UPDATE policy なしのため「標記為已審閱」ボタンが無反応(migration 070、2026-05-15)。
Supabase Client UPDATE — 0-row Silent Success Guard
任何 client-side supabase.from(T).update(...).eq("id", x) 在以下三種情況都會回傳 error: null + 空 data,supabase-js 不會丟錯:
- RLS policy 過濾掉(角色不對、anon 落地)
- JWT expired,supabase-js 來不及自動 refresh
id 在 DB 不存在
三者透過 error 無法區分。Vercel 滾動部署期間 access token 短暫失效正是情境 (2),曾於 2026-05-15 在 production 同時打掉 toggle / work 指派 / AI 報錯 checkbox 三個入口。
規則: 後台所有 client-side UPDATE 必須加 .select("id") 並檢查回傳列數,0 列視為失敗:
const { error, data } = await supabase
.from("events")
.update(update)
.eq("id", eventId)
.select("id");
if (error) {
alert(`操作失敗:${error.message}`);
} else if (!data || data.length === 0) {
alert("操作未生效(session 可能已過期),請重新整理頁面後再試。");
} else {
}
已知需套用的呼叫點(截至 2026-05-15):
web/components/IsActiveToggle.tsx handleToggle
web/components/AdminEditClient.tsx handleSave
web/components/AdminEventTable.tsx handleToggleActive / handleBulkToggleActive / handleBulkForceRescrape / category bulk update / handleSaveWork
web/components/AdminCreatorsClient.tsx toggleActive
web/components/AdminReportsTable.tsx 所有 bulk-confirm 路徑
替代設計:高風險寫入改走 server action / route handler 用 service role key,繞過 client JWT 過期問題。
RLS Policy Matrix Completeness Guard(建表時 SELECT/INSERT/UPDATE/DELETE 缺一即 silent failure)
任何含 admin 後台 UPDATE/DELETE 入口的表,建表 migration 必須同時建立對應的 RLS policy。缺失任一動詞的 policy,PostgREST 會靜默拒絕(0 rows affected,error: null),符合「0-row Silent Success」模式——前端看不出來,DB log 也只是普通 RLS deny。
規則: 設計新表 migration 時,逐一檢查 admin UI 是否會對該表執行:
| 動詞 | 觸發場景 | 必要 policy |
|---|
| SELECT | 列表頁、詳情頁 | FOR SELECT |
| INSERT | 新增按鈕、表單送出 | FOR INSERT |
| UPDATE | 「標記為…」按鈕、toggle、編輯 | FOR UPDATE |
| DELETE | 「刪除」按鈕 | FOR DELETE |
任一動詞會被 admin UI 觸發但 policy 不存在 → 後續一定要回頭補一支 migration(如 070_research_reports_update_policy.sql)。
已知 incident: Migration 008_research_reports.sql 只建 SELECT policy,admin「標記為已審閱」按鈕 silent failure(2026-05-15 補 migration 070)。
偵測 SQL(在 Supabase Dashboard 跑):
SELECT tablename, cmd, count(*)
FROM pg_policies
WHERE schemaname = 'public'
GROUP BY tablename, cmd
ORDER BY tablename, cmd;
Admin Mutation 成對原則(confirm / dismiss / toggle)
admin mutation handler は必ずペアで同じ実装パターンを使う。
confirmReport が server action ならば dismissReport も server action でなければならない。片方だけ昇格させると、残った方が 0-row silent success トラップに落ちる(2026-05-15 handleDismiss 事例)。
チェックリスト(新規 admin mutation を追加する前に確認):
- 同じテーブル・同じ権限を必要とする配対 handler が他にないか?
- 配対 handler が client-side UPDATE のままなら、同時に server action へ昇格させる。
- server action は
.select("id") + data.length === 0 guard + return { ok: false, error } パターンを必ず含める。
- client-side handler は
result.ok を確認し、false の場合 alert(result.error) で即時フィードバックを表示する。
"use server";
import { createClient } from "@/lib/supabase/server";
export async function dismissReport(reportId: string): Promise<{ ok: boolean; error?: string }> {
const supabase = await createClient();
const { error, data } = await supabase
.from("event_reports")
.update({ status: "dismissed" })
.eq("id", reportId)
.select("id");
if (error) return { ok: false, error: error.message };
if (!data || data.length === 0) return { ok: false, error: "0 rows updated" };
return { ok: true };
}
URL 存在確認 — apex と www の両方を試す
会場・組織の公式サイト有無を curl で確認する際は apex ドメインと www. サブドメインの両方を必ず試すこと。
curl -s --max-time 5 -o /dev/null -w "%{http_code}" "https://example.jp/"
curl -s --max-time 5 -o /dev/null -w "%{http_code}" "https://example.jp/"
curl -s --max-time 5 -o /dev/null -w "%{http_code}" "https://www.example.jp/"
000 = curl が DNS 解決に失敗した = 「その変形が存在しない」であり「サイト全体が存在しない」ではない
2xx が返るまで apex / www / 短縮ドメインを試し、すべて失敗した場合のみ「公式サイトなし」と判断する
- 参照インシデント:
coconeri.jp → 000、www.coconeri.jp → 200(2026-05-17、event eeb5b12e)
annotation_status エラーイベントの定期リセット
annotator.py は annotation_status='pending' のみ処理する。'error' になったイベントは自動リトライされない。
確認コマンド(定期的に実行):
res = sb.table('events').select('id,name_ja', count='exact') \
.eq('annotation_status','error').eq('is_active', True).execute()
print(f"error count: {res.count}")
リセット手順:
ids = [r['id'] for r in res.data]
sb.table('events').update({'annotation_status': 'pending'}).in_('id', ids).execute()
出版來源の追加規則:
ndl_opensearch / hanmoto / kawade_rss は scraper/annotator.py::_PUBLICATION_SOURCES と source SKILL の出版模板規則を同時に同期する。片方だけ更新すると「規範有、程式碼漏」が再発する。
- 出版來源の error backlog は source-scoped one-off で reset する。
eslite_spectrum は混合來源なので同じ batch に混ぜない。
- reset 対象は
is_active=true かつ annotation_status='error' を基本にし、field_corrections が 1 件でもあるイベントは丸ごと skip する。人工修正済みイベントを再注入しないため。
- batch 実行前に live DB が migration
047_add_broadcast_event_form.sql を反映済みか確認する。未反映だと event_form=['publication'] の書き戻しが events_event_form_check で失敗する。
- publication 系の修正で
event_form を追加・強制する場合は、同じ変更で 4 箇所を同期する: Supabase constraint / migration、scraper/annotator.py(VALID_EVENT_FORMS と source-specific overwrite)、web/lib/types.ts の EVENT_FORMS、web/messages/*.json の eventForm namespace。どれか 1 つ欠けると 23514 constraint error か raw key 表示になる。
- publication batch の完了判定は
annotation_status だけでは不十分。対象 source の active rows が期待する event_form に収斂していることを DB query で確認し、前台で raw key が出ないところまで確認する。
注意事項:
daily_report.py は .limit(5) でエラー件数を表示するため、実際の件数と一致しない。COUNT で別途確認すること。
- 薄い
raw_description(1行のみ)の sub-event も、親イベントのコンテキストを参照することで正常アノテーション可能。
- error 件数が多い場合は
annotator.py の GPT JSON パースロジックに問題がある可能性もある(レスポンス形式の変化等)。
Async Fetch AbortSignal Timeout Guard(UI 永久 loading 防護)
後台任何「觸發外部 API + 等待結果 + 更新 UI 狀態」的 client-side fetch 必須加 signal: AbortSignal.timeout(N) 防護:
問題場景: fetch("/api/admin/annotate-event", ...) 無 AbortSignal → Vercel function 被 gateway 截斷時(504 但無正常 HTTP 回應),await fetch() 永遠 pending → setAnnotating(false) 不執行 → UI 卡在「標注中,請稍候…」。
規則:
const res = await fetch("/api/admin/annotate-event", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ eventId }),
signal: AbortSignal.timeout(58000),
});
const openaiRes = await fetch("https://api.openai.com/v1/chat/completions", {
...
signal: AbortSignal.timeout(25000),
});
已修復(commit 77fc092,2026-05-15):
AdminEventTable.tsx handleSaveAndAnnotate 的 annotate-event fetch
annotate-event/route.ts OpenAI call
AdminEventTable.tsx handlePublish 補 .select("id") + 0-row guard
React busy timer reset rule
Client component 如果要顯示 save / extract / annotate 的 elapsed 秒數,useEffect 只能負責 timer 的啟停與 cleanup,不要在 effect body 內同步呼叫 setState 做 reset。React 19 lint 會以 react-hooks/set-state-in-effect 直接報錯。
Rules:
- 將
setBusyElapsedMs(0) 這類 reset 與 busyStartedAtRef.current = Date.now() 放在事件入口,例如 handleSave、handleImageExtract、beginPrimaryAction。
useEffect 只做三件事:判斷 timer 是否需要運行、掛上 setInterval、在 cleanup 清除 interval。
- busy 結束時可以清空 ref;若 UI 需要下一次從
0s 開始,應在下一次 action 開始時設定,不要在 effect 的 idle branch 內 reset。
Reference incident: 2026-06-04 OwnerCreateClient.tsx targeted lint FAIL.
Client Component 直接 INSERT 禁止 — Server Action 義務化
Client Component 内で supabase.from(...).insert() を直接呼び出してはならない(admin・一般ユーザー問わず)。
ブラウザ→PostgREST のリクエストがネットワークレベルでハングすると、await は永遠に pending → try/catch は発動しない(thrown error ではなく hanging fetch のため) → setStatus("error") が呼ばれない → ボタンが loading 状態で固まる(2026-05-15 ReportSection commit 53445be、2026-05-20 AdminEventTable Safari hang)。
規則:
- ユーザー向けフォームの INSERT(anon・authenticated 問わず)は必ず Server Action で行う。
- RLS で
anon INSERT を許可していても、ブラウザ直接 INSERT はハング耐性がない。
- Safari は特に hang しやすい。Chrome で動いても Safari で再現する。
暫定防護(Server Action 化が間に合わないとき): withClientTimeout(promise, ms, label) helper で supabase.from(...).insert/.update() を包む。Promise.race + setTimeout で hard cap reject → catch 分岐が必ず発動 → setSaving(false) 復帰。
- insert:20 秒
- update(軽い):15 秒
- bulk handlers + works fetch 等、Server Action 化が未完了の path 用の保底メカニズム。
完了済み Server Action 化(2026-05-22 commit): web/components/AdminEventTable.tsx の handleSaveNew / handleSaveAndAnnotate / handlePublish は @/app/actions/admin-events.ts の createEventNoAnnotate / createDraftEvent / publishEvent を呼び出す。共通 admin 認証は @/app/actions/_shared/admin-guard.ts (requireAdmin())。withClientTimeout ラップは defense-in-depth として保持。
残存技術債(同 file 内 7+ 處): AdminEventTable.tsx の bulk handlers(handleBulkToggleActive / handleBulkForceRescrape / handleBulkRemoveCategory / handleBulkAssignWork / handleBulkAddCategory)と単列 toggle(force_rescrape / is_active)はまだ client-side supabase.from("events").update() を使用。Safari hang が再発したら同 pattern で Server Action 化する。
function withClientTimeout<T>(p: PromiseLike<T>, ms: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
Promise.resolve(p).then(
(v) => { clearTimeout(timer); resolve(v); },
(e) => { clearTimeout(timer); reject(e); },
);
});
}
annotate-event SELECT 紀律(ユーザー入力欄を守る)
web/app/api/admin/annotate-event/route.ts は 「OCR 已填值は保持、空欄のみ GPT 補完」 パターン:
const cur = (event as Record<string, unknown>)[k];
if (cur === null || cur === undefined || cur === "") {
returnedFields[k] = v;
}
これは SELECT 句で fetch したフィールドにしか効かない。SELECT 漏れ = undefined = 空判定 = サイレント上書き。
規則:
extractionFields 配列に列挙したフィールドは すべて L258 の SELECT 句に含める。
- 新規「保持したい」フィールドを追加するときは 3 箇所同時編集:
- L258 SELECT 句
extractionFields 配列(L437 付近)
- 必要なら
alwaysOverwriteFields(description 系のみ)
- PR レビュー時:
extractionFields の各 key が SELECT 句にあるか目視確認。
Reference incident: 2026-05-20 — end_date が extractionFields にあるのに SELECT に無く、ユーザーが画面で選択した end_date が毎回 GPT 幻覚(例:2023-10-14)で上書きされていた(commit e0a5ea8)。
- Server Action はネットワークハング時も Next.js が適切なエラーレスポンスを返すため
catch ブロックが確実に発動する。
const supabase = createClient();
const { error } = await supabase.from("event_reports").insert({ ... });
const result = await submitReport({ eventId, reportTypes, locale, suggestedCategory });
if (!result.ok) { setStatus("error"); return; }
参照: web/app/actions/submit-report.ts(user-facing)、web/app/actions/dismiss-report.ts(admin)
Auth — ブラウザ SIGNED_OUT を単独の真実源にしない / route handler <Link> の prefetch 副作用(2026-06-03 教訓)
ログイン状態を扱う Client Component(Navbar など)の二大落とし穴。両方とも「ログイン直後に勝手にログアウトされる」症状を出す。
① onAuthStateChange の SIGNED_OUT を信用しない — 必ずサーバで再検証する。
Supabase SSR では proxy.ts(middleware)・/api/me・ブラウザ client が同じ refresh token をほぼ同時にローテーション要求する。token rotation 有効環境ではレースの敗者が Invalid Refresh Token を受け取り、ブラウザ client が偽の SIGNED_OUT を発火する。このときサーバ session はまだ有効なので、SIGNED_OUT で無条件に setUser(null) するとログイン直後に未ログイン表示へ戻る(フリッカー)。
supabase.auth.onAuthStateChange((event) => {
if (event === "SIGNED_OUT") { setUser(null); return; }
void loadMe();
});
supabase.auth.onAuthStateChange(() => { void loadMe(); });
② <Link> が route handler(副作用を伴う GET)を指す場合は必ず prefetch={false}。
Next.js の <Link> はホバーで対象 URL を prefetch する。対象が route handler だと実 GET が飛ぶ。ログアウトリンク(/auth/logout → supabase.auth.signOut())を <Link> で置くと、メニュー内で隣の項目へマウスを動かしただけでホバー prefetch が signOut() を実行し、ユーザーが勝手にログアウトする。
- ログアウト等、副作用を伴う route handler への
<Link> には prefetch={false} 必須。
- 受保護ルート(
/saved・/account 等)への <Link> も prefetch が login へ redirect されるため prefetch={false} が安全。
- クライアント側ロジックでナビゲートする項目(権限分岐など)は
<Link> ではなく <button onClick> にして prefetch 自体を発生させない。
Reference incident: Navbar auth flicker + hover logout(2026-06-03 commit c4e1bde).
Supabase Realtime
Supabase Realtime is NOT enabled by default for tables. Frontend .on("postgres_changes", ...) subscriptions will silently never fire unless the table has been added to the publication first.
Required database step before any Realtime feature can work:
ALTER PUBLICATION supabase_realtime ADD TABLE <table_name>;
Required migration pattern:
- Create a new migration SQL file:
ALTER PUBLICATION supabase_realtime ADD TABLE <table>;
- Apply via Supabase Dashboard SQL editor (or CLI)
- Only then will the frontend subscription receive events
Diagnosis: Supabase Dashboard → Database → Replication — confirm the target table appears in the supabase_realtime publication list.
Frontend subscription rules:
- Create the Supabase client INSIDE
useEffect — never at component top level. Top-level creation creates a stale closure risk: the useEffect captures the initial instance and may reference a stale/invalidated client after re-renders.
- Admin pages must subscribe to both INSERT and UPDATE — INSERT picks up new rows; UPDATE syncs status changes (confirm/dismiss) across open tabs.
- Always clean up in
useEffect return: supabase.removeChannel(channel).
- Extract a
fetchRow(id) helper when both INSERT and UPDATE handlers need to re-fetch the full row (avoids duplicating the SELECT clause).
useEffect(() => {
const rt = createClient();
const channel = rt
.channel("table_changes")
.on("postgres_changes", { event: "INSERT", schema: "public", table: "t" }, handleInsert)
.on("postgres_changes", { event: "UPDATE", schema: "public", table: "t" }, handleUpdate)
.subscribe();
return () => { rt.removeChannel(channel); };
}, []);
const supabase = createClient();
useEffect(() => {
const channel = supabase.channel(...);
}, []);
Server Component + Realtime 分離模式:
Server Component 中的動態 UI(badge、計數器)如果需要即時性,必須拆出為 Client Component:
const pending = await getPendingCount();
return <AdminReportsBadge initialCount={pending} />;
const [count, setCount] = useState(initialCount);
useEffect(() => {
const rt = createClient();
const ch = rt.channel("badge")
.on("postgres_changes", { event: "INSERT", ... }, () => setCount(c => c + 1))
.on("postgres_changes", { event: "UPDATE", ... }, async () => setCount(await fetchCount()))
.subscribe();
return () => { rt.removeChannel(ch); };
}, []);
Reference incident: AdminTabNav badge(2026-05-02)— SSR-only badge 在報告提交後數字不更新,直到建立 AdminReportsBadge client component 才修復(commit 4a71258).
supabase
.channel("event_reports_changes")
.on("postgres_changes", { event: "INSERT", schema: "public", table: "event_reports" }, handleInsert)
.on("postgres_changes", { event: "UPDATE", schema: "public", table: "event_reports" }, handleUpdate)
.subscribe();
Next.js SSR 快取無效化(router.refresh())
Admin Client Component 執行 mutation 後導航回 SSR 頁面,必須先呼叫 router.refresh()。
Next.js App Router 的 router cache 不會因為 router.push() 自動失效;若省略 router.refresh(),SSR 頁面將繼續顯示 mutation 前的快取資料(如舊的 is_active、annotation_status)。
await saveToDb(data);
router.push('/admin');
await saveToDb(data);
router.refresh();
router.push('/admin');
適用場景(兩種更新模式,應同時部署):
| 場景 | 解法 |
|---|
| 同一 tab 內的即時 row 更新(新報告、狀態變更) | Supabase Realtime 訂閱(INSERT + UPDATE) |
| 跨頁面導航後列表顯示最新資料 | router.refresh() before router.push() |
規則: 任何 Admin 頁面的 save / confirm handler,只要後面接 router.push(),一律在其前加 router.refresh()。這是 Next.js App Router 的必要模式,不是 optional 優化。
⚠️ 逆に stay-on-page handler(router.push() を伴わない dismiss / toggle など)では router.refresh() を呼ばない。 router.refresh() が RSC 再レンダリングをトリガーし、Realtime UPDATE イベントと同時着火することで state/render race → 画面破損が起きる(2026-05-15 handleDismiss commit 390826a)。ローカル state(setReports())と Realtime 購読で十分。
| ハンドラータイプ | router.refresh() | 理由 |
|---|
confirm(event fields 変更 → router.push() あり) | ✅ 必要 | SSR キャッシュ無効化が必要 |
| dismiss(report status のみ変更、stay-on-page) | ❌ 不要・禁止 | Realtime と RSC re-render が競合し画面破損 |
Python
- When changing a function's return type (e.g.
dict → tuple), immediately smoke-test before committing: python -c "from module import fn; print(type(fn(...)))"
- Use
getattr(obj, 'attr', default) when reading an attribute that may not exist on all subclasses.
- Pipeline parity rule: Any post-processing step added to CI workflow (
scraper.yml) must also be called in main.py's normal (non-dry-run) flow. Otherwise manual scraper runs produce incomplete results. Current full pipeline in main.py: scrape → merger → annotate → enrich_movie_titles() → enrich_person_names() → IndexNow. Enrich functions are idempotent — double execution (main.py + CI) is safe (just extra DB queries). When adding a new enrichment function: (1) add to main.py after annotate_pending_events(); (2) add CLI flag + step to scraper.yml.
- Inline Python safety rule — Prompt Injection: Never use
python3 -c "..." or heredoc python3 << 'PY' ... PY for scripts that interpolate DB values (e.g. f-strings over field_corrections.corrected_value, event fields, or any user-supplied text). Two attack vectors: (1) shell history pollution injects code into f-string braces; (2) DB data itself can contain injected shell commands — rows in field_corrections or other tables may embed rm -f ... or similar, which then appear inside {...} braces and corrupt the inline script. Rule: Always use create_file /tmp/<name>.py + python3 /tmp/<name>.py for any script that queries the DB and formats results with f-strings. File-based execution is fully isolated from the shell. If SyntaxError points at a { in -c mode, treat it as a prompt injection alert and switch to file mode immediately.
requests.Session() must always mount HTTPAdapter with Retry: Any scraper using requests.Session() must mount a retry adapter in __init__. Without it, a single transient network blip from GitHub Actions runners raises Max retries exceeded and triggers Sentry. Required pattern:
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
_retry = Retry(
total=3,
backoff_factor=2,
status_forcelist=[429, 500, 502, 503, 504],
raise_on_status=False,
)
self._session.mount("https://", HTTPAdapter(max_retries=_retry))
self._session.mount("http://", HTTPAdapter(max_retries=_retry))
Backoff: 2s → 4s → 8s. Apply to both https:// and http:// mounts.
Next.js / Sentry
- Server Component から client module の純関数を呼ばない:
"use client" file exports belong to the client module graph. If a Server Component needs a parser, filter builder, or URL state helper, put the type/function in a server-safe web/lib/*.ts module and import only client components/hooks from the client file. Do not add compatibility re-exports from client context modules. Reference incident: 2026-06-03 homepage buildInitialFilters() render error.
- Never set
autoInstrumentServerFunctions: false — it silently disables server-side error capture.
- Gate source map upload:
sourcemaps: { disable: !process.env.SENTRY_AUTH_TOKEN }.
API Route JSON Safety Guard(Safari SyntaxError 防護)
所有 API route POST handler 必須用 try/catch 包整個函式體,確保任何情況下都回傳 JSON。
Safari 的 fetch().then(r => r.json()) 遇到非 JSON 回應(如原始 HTML error page)直接拋 SyntaxError: The string did not match the expected pattern.;Chrome 在同樣情況下靜默失敗。不包 try/catch 的 route 在 uncaught exception 時回傳 HTML 500,Safari 用戶看到崩潰,Chrome 用戶靜默無感——跨瀏覽器行為完全不同,難以 debug。
export async function POST(request: Request) {
const data = await request.formData();
return NextResponse.json({ url: "..." });
}
export async function POST(request: Request) {
try {
const data = await request.formData();
return NextResponse.json({ url: "..." });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Server error";
return NextResponse.json({ error: message }, { status: 500 });
}
}
配套規則:
- File extension 必須消毒:從 user-uploaded filename 取副檔名前必須過濾特殊字元,防止破壞 Supabase storage path:
const rawExt = file.name.split(".").pop() ?? "jpg";
const ext = rawExt.replace(/[^a-zA-Z0-9]/g, "").slice(0, 10) || "jpg";
- Client side fallback:
const json = await res.json().catch(() => ({ error: "Upload failed (server error)" })) — 防止 non-JSON 500 在 client 端拋 SyntaxError。
SUPABASE_SERVICE_ROLE_KEY 必須顯式 guard:若依賴 service role 的 route 缺 key,要回傳明確錯誤訊息而非 undefined 引爆 TypeError:
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!serviceKey) return NextResponse.json({ error: "Server misconfiguration: storage key not set" }, { status: 500 });
Reference incident: 2026-05-31 — web/app/api/upload/route.ts 未包 try/catch + extension 未消毒,Safari 上傳圖片全失敗;Chrome 無感(commit 616eecc)。
Homepage vs EventCard — Two Render Paths
事件卡片在本專案有兩條獨立渲染路徑,修改前必須同時檢查:
| 路徑 | 檔案 | 用途 |
|---|
| 共用元件 | web/components/EventCard.tsx | saved、category、search 等其他頁面 |
| 首頁 inline | web/app/[locale]/page.tsx(行 ~290 的 events.map) | 首頁專用,不 import EventCard |
修卡片視覺前的 SOP:
grep -rn '<EventCard\|EventCard ' web/app/ web/components/ 列出所有 call site。
- 確認首頁使用的渲染路徑(首頁是 inline,不是 EventCard)。
- 共用邏輯(
getCityLabel、日期格式、地址解析等純函式)抽至 web/lib/<name>.ts,兩處 import;切忌在兩處複製貼上同樣邏輯。
- Vercel 部署後驗證:
curl https://tokyotaiwanradar.com/zh | grep -c '<新元素 class>',期望非 0。
Reference: 2026-05-05 城市徽章兩輪才生效(commit 5a29c13 → 9f4b468)。
Event Detail Page — Performer Display Priority
web/app/[locale]/events/[id]/page.tsx 的 performer 顯示邏輯必須依照以下優先序:
- zh/en locale + performer_zh/en 存在 →
getEventPerformer(event, locale)(多語言欄位優先)
- performers[] 非空 →
performers[].join("、")(日文多人列表 fallback)
- fallback →
getEventPerformer(event, locale)
關鍵規則:performers[] 是日文陣列,永遠不可作為 zh/en locale 的主要顯示來源。新增 performer_zh/performer_en 欄位後,必須同步更新此優先序,否則新欄位永遠不會被 end-user 看到(隱性迴歸)。
Reference incident: 2026-05-09 — migration 054 新增多語言欄位後 UI 未同步,zh 頁面始終顯示日文(commit 2e6f4c2)。
performer / performers[] / performer_zh/en — 三欄架構
| Field | Type | 職責 |
|---|
performer | TEXT | 日文單人主表演者;annotator GPT/regex 輸出;getEventPerformer() fallback 錨點 |
performer_zh / performer_en | TEXT | performer 的語言翻譯(一對一對應);GPT 填入或人工設定 |
performers[] | TEXT[] | 日本語(カタカナ)多人陣列;日本語ソースページのキャスト表記を使用;performers_zh[] は繁体字対応 |
performers_zh[] | TEXT[] | performers の繁体字対応;getEventPerformer(event,'zh') が参照する |
performers_en[] | TEXT[] | performers の英語対応;getEventPerformer(event,'en') が参照する |
⚠ 絕不可刪除 performer:performer_zh/en 錨定於它;34+ 處程式碼引用它;performers[] 有多人時翻譯欄位才有意義。
Auto-sync 規則(commit 4526d3a):annotator 滿足以下四個條件時自動設 performers = [performer]:
- 本次 pass 設定了
performer
- 現有
performers[] 為空/null
- GPT 本次未回傳
performers 陣列
performers 未在 field_corrections 保護中
此機制確保 UI 永遠能從 performers[] 讀取,不需在前端 fallback 回 performer。
performers[] 言語規則:performers[] は必ず**日本語(カタカナ)**で入力する。繁体字 film DB・works.cast_summary から補完したデータは performers_zh[] に入れること。日本語の映画サイト・劇場サイトのキャスト欄(例:出演:ケイトリン・ファン、ウィル・オー、9m88...)が権威ソース。繁体字が performers[] に混入すると日本語ロケールで漢字が表示される(incident: 霧のごとく 11 件, 2026-05-20)。
performer multi-value 淨化規則(commit c4bd9e1):performer 字段必須是單一人物名。annotator 輸出 performer 時必須經過 _MULTI_SEP_RE([、,,×//])檢查:
- 包含區切符 →
performers[] に分割し、performer / performer_zh / performer_en を None にクリア
enrich_person_names() の B1 策略が performers[] の各名前を ja_to_info で翻訳 → performers_zh/performers_en を生成
- 既存汚染 DB の一括移行は
_oneoff_migrate_multi_performer.py --execute で実行する(--dry-run で事前確認)
TSX Component vs Helper — react-hooks/static-components Rule
Next.js 15+ / React 19 lints any PascalCase function that returns JSX as a React component. Components declared inside another component's render body trigger react-hooks/static-components and fail Vercel build.
Rules:
- A function returning JSX with
PascalCase name is treated as a component → must live at module top level (or be exported). Never declare it inside another component body.
- A render-only helper used inline (not as
<Tag />) must use camelCase and be invoked as a plain function call: {eventLink(event)}, not <EventLink event={event} />.
- If you need closure over parent state, either:
- Lift the helper to module scope and pass needed values as props/args, or
- Use
useMemo / useCallback to derive the JSX, then render {memoizedNode} directly.
export default function Page() {
function SectionHeader({ title }) { return <h2>{title}</h2>; }
function eventLink(e) { return <a href={...}>{e.name}</a>; }
return <SectionHeader title="..." />;
}
function SectionHeader({ title }: { title: string }) { return <h2>{title}</h2>; }
export default function Page() {
return <SectionHeader title="..." />;
}
function renderEventLink(e: Event) { return <a href={...}>{e.name}</a>; }
export default function Page() {
return <ul>{events.map(e => <li key={e.id}>{renderEventLink(e)}</li>)}</ul>;
}
Reference incident: 2026-05-01 /admin/quality page first Tester FAIL — SectionHeader and eventLink declared inside QualityPage render body. Fix: hoisted SectionHeader to module scope; renamed eventLink to renderEventLink and called as function.
OG Image(opengraph-image.tsx)— Edge Runtime 規則
app/[locale]/events/[id]/opengraph-image.tsx 使用 ImageResponse(Satori 引擎),必須設定 export const runtime = "edge"。
Edge runtime 限制:只能用純 Web API。
| 可用 | 不可用 |
|---|
fetch(url) | fetch(url, { next: { revalidate } }) |
Response, Request, TextDecoder | next: { tags }, next: { revalidate } |
ArrayBuffer, Uint8Array | Node.js fs, path, crypto 模組 |
const res = await fetch(url, { next: { revalidate: 86400 } });
const res = await fetch(url);
Google Fonts text API 子集化:
- 使用
?text=... 參數只取頁面用到的字元,大幅縮小 ArrayBuffer 體積。
- CSS 中
src: 與 url( 之間可能有空白;regex 必須用 /src:\s*url\(/,不可寫死 /src: url\(/。
css.match(/src: url\(https:\/\//);
css.match(/src:\s*url\(https:\/\//);
Bulk Action Pattern (AdminEventTable / AdminReportsTable)
When adding a new bulk operation that operates on a derived value from selected events (e.g. common categories, common source, common status):
- Compute the derived value with
useMemo([selected, events]) — never inline in render
- Add a loading state (
useState(false)) guarding the async handler
- Use
Promise.all(selectedEvents.map(...)) for parallel DB updates — do NOT loop with await sequentially
- Apply optimistic local state update in
setEvents() after Promise.all resolves
- Only show the derived-value UI when the derived value is non-empty (conditional render in bulk bar)
- Add i18n keys to all 3 message files using the Python json-module pattern
Exception — sequential loop required when sub-handler has internal setState:
If the bulk handler calls an existing handleXxx that internally calls setSaving (or any React state setter), use await for loop instead of Promise.all. Parallel calls will cause state races.
for (const id of selectedIds) {
await handleConfirm(id);
}
await Promise.all([...selectedIds].map(id => handleConfirm(id)));
Checkbox in list rows — wrapper element rule
When a list row is currently a <button> and you need to add a checkbox:
- DO NOT put the checkbox inside the
<button> — violates HTML spec (interactive element inside button)
- Refactor wrapper:
<button className="w-full ..."> → <div className="flex items-stretch"> + <label> checkbox </label> + <button className="flex-1 ...">
- On the checkbox label, add
onClick={(e) => e.stopPropagation()} to prevent bubble triggering row expand/collapse
<div className="flex items-stretch">
<label onClick={(e) => e.stopPropagation()} className="flex items-center px-2 cursor-pointer">
<input type="checkbox" checked={...} onChange={...} />
</label>
<button className="flex-1 text-left ..." onClick={handleExpand}>
{/* row content */}
</button>
</div>
Bulk action bar placement
Place bulk action bar in the section header (flex justify-between), not a bottom footer bar — more intuitive for list sections with variable item counts.
Sticky filter + bulk action container
Filter bar and bulk action bar must be wrapped in a single sticky container (sticky top-14 z-20). Separate sticky elements for each bar cause jittering on scroll. Incident: commits 15d6ab3→bf22756.
Bulk add category (AdminEventTable)
The bulk action bar includes a "新增分類" row with a multi-select category picker (same CATEGORY_GROUPS layout as inline editor). Implementation:
- State:
bulkAddCatPending: Set<string> tracks pending selections, bulkAddCatOpen: boolean toggles dropdown
- Merge:
new Set([...prevCategory, ...catsToAdd]) — never duplicates
- Writes
category_corrections for AI feedback loop (same as single-event category edit)
- Auto-resets
selected Set on success (same as bulk remove)
AdminReportsTable — Protected Feature: Bulk Confirm
⚠️ This feature MUST NOT be removed unless a PRD explicitly requests it. Any refactor touching AdminReportsTable.tsx must verify all of the following remain intact:
| State / function | Purpose |
|---|
selectedIds: Set<string> | Tracks which pending rows are checked |
bulkConfirming: boolean | Loading guard during bulk operation |
handleBulkConfirm(rows: ReportRow[]) | Sequential loop calling handleConfirm per id, then clears selectedIds |
Row structure (each pending row):
<div className="flex items-stretch">
<label onClick={(e) => e.stopPropagation()} className="flex items-center px-2 cursor-pointer">
<input type="checkbox" checked={selectedIds.has(row.id)} onChange={...} />
</label>
<button className="flex-1 text-left ..." onClick={handleExpand}>
{/* row content */}
</button>
</div>
Bulk action bar (section header, flex justify-between):
- 「取消選取」→
t("bulkCancelSelect")
- 「通過已選取 (n)」→
t("bulkConfirmSelected", { count })
- 「全部通過 (n)」→
t("bulkConfirmAll", { count })
Why sequential loop (not Promise.all): handleConfirm internally calls setSaving (React state setter). Parallel calls cause state races. Always use for...of with await.
Incident: commit 3d45de6 removed bulk confirm alongside Realtime subscription removal. Restored in 4c30ab3. Do NOT repeat.
Admin Quality Page — Supabase Query Rules
Quality page is_active filter rule: Every Supabase query on the quality page (/admin/quality) must include .eq("is_active", true). Without it, deactivated events appear in the list; clicking them returns 404 because the detail page only renders active events. Apply to all section queries — missingAddr, missingCat, reviewedMissing, annotatedNoCat, and any future additions.
Quality section actionability rule: Only include a Quality section if its value can be cleared to zero by user action (fix button / batch action) or by an automated process running within a reasonable interval. If a section's value persists indefinitely due to scheduling (e.g. archive cron runs once daily, so daytime-expired events always appear) and no actionable button exists, remove the section entirely. Display of permanently non-zero unactionable data erodes trust in the quality page. Reference incident: expired-but-active section removed in commit cd4cc29.
missingAddr filter exclusion list: The "缺地址" section must exclude all of the following:
- DB:
location_name containing 「オンライン」 (online events) — .not("location_name", "ilike", "%オンライン%")
- DB:
location_name containing 「電視頻道」 (TV channel events)
- DB: events with
source_name = 'gguide_tv' (TV guide source)
- DB:
location_name containing 〒 (address already embedded in name) — .not("location_name", "like", "%〒%")
- DB:
location_name containing ・ (multi-city events — location_name uses ・-joined city names by convention; no single address exists)
- Client: short geographic name ≤6 chars with no spaces (e.g.
東京, 香港, 岡山, 文京区 — no actionable address)
Archive cutoff 一致性規則: database.py 的 archive_ended_events() cutoff 必須與 quality page 的 today 截止一致。
⚠️ archiver 已刪除(2026-05-06):archive_ended_events() 已從 database.py 和 main.py 完全移除。Quality page 的截止邏輯不再有對應的自動下架機制;事件 is_active 改為純手動管理。此規則已廢止。
Admin list page link rule: Event hyperlinks in admin list pages (quality, reports, etc.) must point to /{locale}/events/{id} (detail page, target="_blank"), not /{locale}/admin/{id} (edit page). Quality pages are for review, not editing.
i18n JSON File Editing — Unicode Safety Rule
Never use replace_string_in_file to edit web/messages/*.json when oldString contains any non-ASCII characters (Japanese/Chinese punctuation, CJK characters, fullwidth symbols like ・ U+30FB). The tool can silently fail to match without reporting an error.
Always use the Python json-module pattern for i18n edits:
import json, pathlib
path = pathlib.Path('web/messages/XX.json')
data = json.loads(path.read_text(encoding='utf-8'))
data['section']['key'] = 'new value'
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
After writing, verify with grep "key" web/messages/XX.json before committing.
replace_string_in_file is safe only for ASCII-only strings in JSON files.
Namespace placement rule: Always use data["<namespace>"]["key"] = value, never data["key"] = value (top-level). next-intl t("key") only searches within the namespace declared in useTranslations("<namespace>") — missing keys silently render as the key name string (no error thrown). Confirm the target namespace from the component's useTranslations() call:
| Component / Usage | Namespace |
|---|
| FilterBar location/time options | filters |
| Category labels, group names | categories |
| Other keys | Check useTranslations("X") at the call site |
After adding a key, verify placement with grep -n "newKey" web/messages/zh.json — line number should be within the expected block, not at the bottom of the file (top-level).
Reference incident: locationOverseas written to top-level (L400+) instead of filters.{} (L10–L40) — production FilterBar rendered key name string, timeMode default disappeared (commit 049edd8).
FilterBar timeMode 預設值規則
FilterBar.tsx 的每個 timeMode 切換必須同步設定合理的 URL 預設值:
| 切換到 | 動作 |
|---|
active | 清空 from/to,立即 push |
all | 清空 from/to,立即 push |
past | 若 to 為空,預填今日(new Date().toISOString().slice(0,10)),立即 push |
Rule: 不能讓 EventListClient 在 URL 參數不足時猜測 mode 的含義。每個 mode 必須有完整的 URL 狀態才能正確過濾。
} else if (e.target.value === "past") {
const today = new Date().toISOString().slice(0, 10);
setDraft((prev) => {
const next = { ...prev, timeMode: "past", to: prev.to || today };
pushWith(next);
return next;
});
}
Reference incident: 2026-05-14 — 切換 past 後畫面不動,因 from/to 皆空,EventListClient past 分支無任何過濾條件執行(commit 2ed3c7b)。
zsh git add with glob bracket paths:
Any path containing [...] (e.g. web/app/[locale]/page.tsx) will fail in zsh with no matches found because zsh expands brackets as glob patterns.
Rule: Always wrap such paths in single quotes:
git add web/app/[locale]/page.tsx
git add 'web/app/[locale]/page.tsx'
Applies to all shell operations: git add, cp, mv, cat, etc.
GITHUB_TOKEN Permission Wording Sync
When touching any implementation or docs related to --create-issue and GITHUB_TOKEN, enforce one canonical wording only:
- Fine-grained PAT:
Issues: write + Metadata: read
- Classic token:
repo scope
In the same change set, sync all related layers:
- Runtime/error text in
scraper/update_source.py
- Operational docs in
docs/GITHUB_TOKEN_SYNC_CHECKLIST.md
- Rotation instruction in
.github/instructions/token-rotation.instructions.md
- Agent usage docs in
.github/agents/researcher.agent.md
- Lifecycle summary in
.github/SECRETS_LIFECYCLE.md
Never leave non-standard Issues permission wording (e.g. the &-separated form) in any tracked file.
Secrets Checklist Ownership
docs/GITHUB_TOKEN_SYNC_CHECKLIST.md is the single source of truth for the token sync checklist.
.github/TOKEN_SYNC_CHECKLIST.md must remain a redirect stub only.
- Do not maintain duplicate checklist content in multiple files.
- For public repo safety, examples must use placeholders (
github_pat_xxx), never real values.
Category Update Protocol
Canonical source of truth: web/lib/types.ts → Category union type, CATEGORIES array, CATEGORY_GROUPS array.
When renaming a category display label (i18n only)
Update all three message files simultaneously:
web/messages/zh.json — key under categories.*
web/messages/en.json — same key
web/messages/ja.json — same key
For group labels: keys are group_arts, group_lifestyle, group_knowledge, group_society, group_archive.
When adding or removing a category value
Update all 6 locations in a single commit — do NOT split across commits:
web/lib/types.ts — Category union type
web/lib/types.ts — CATEGORIES flat array
web/lib/types.ts — CATEGORY_GROUPS (place in the correct group)
web/messages/zh.json — label under categories.*
web/messages/en.json — same key
web/messages/ja.json — same key
When reorganizing category groups (moving categories between groups)
- Modify only
CATEGORY_GROUPS in web/lib/types.ts — no component code changes needed.
AdminEventForm.tsx, ReportSection.tsx, and AdminReportsTable.tsx all read CATEGORY_GROUPS at runtime; they auto-reflect any reorganization.
group_knowledge canonical members: business, academic, lecture, taiwan_japan.
group_lifestyle includes: competition, workshop, exhibition, books_media, tv_program, healthcare.
Incident: 2026-05-01 — competition, workshop, exhibition, books_media, tv_program moved from group_knowledge → group_lifestyle (commits a07b792, 5b66c33). Only CATEGORY_GROUPS in types.ts required changing.
6 UI surfaces that consume categories (all derive from types.ts — no component code changes needed for label renames)
| Surface | File | Source | Type |
|---|
| 前台篩選器 | web/components/FilterBar.tsx | CATEGORY_GROUPS + messages/categories.* | 選擇器 |
| 後台篩選器 | web/components/AdminEventTable.tsx | CATEGORY_GROUPS + messages/categories.* | 選擇器 |
| AI 報錯選單 | web/components/ReportSection.tsx | CATEGORY_GROUPS + messages/categories.* | 選擇器 |
| 活動編輯頁 | web/components/AdminEventForm.tsx | CATEGORY_GROUPS + messages/categories.* | 選擇器 |
| 後台問題回報審核 | web/components/AdminReportsTable.tsx | CATEGORY_GROUPS + messages/categories.* | 選擇器 |
| 首頁活動卡片標籤 | web/components/EventCard.tsx | messages/categories.* only | 展示用 |
Note: EventCard.tsx renders category tags on the homepage card — display-only, no picker. Label renames propagate automatically.
Category group picker layout — paired-file rule
AdminEventForm.tsx, ReportSection.tsx, and AdminReportsTable.tsx all render the category group picker with CATEGORY_GROUPS. They must use the same layout at all times:
- Structure:
grid-cols-[4.5rem_1fr] per group row — col 1 = group label (right-aligned, shrink-0), col 2 = flex-wrap tags
- Any layout change to any one of these three files must be applied to all three in the same commit
- Do NOT use
flex-wrap with a mixed label+tags row — overflows cause label misalignment when a group has many items
AdminEventTable.tsx — Protected Invariants
Whenever this file is modified for any reason, verify these 3 lines are intact before committing:
- Search filter label:
{t("name")} — tFilters namespace does NOT exist; using tFilters("search") silently renders the raw key string
- Category filter label (in filter bar, not table column header):
{t("category")} — same reason, do NOT use tFilters("category")
- Category dropdown button:
bg-white — do NOT revert to bg-gray-50
These were regressed at least twice when unrelated changes overwrote them. The tFilters mistake specifically recurred because old SKILL notes incorrectly listed it as the correct value.
Work dropdown modal rule: The "+ 建立新作品" entry in the per-row work <select> dropdown must call setShowCreateWorkModal(true) — never use <a href> or router.push. This keeps the UX consistent with the bulk action bar "+ 新增作品" button. Reference: commit 4a266a1.
Column pairing rule: When adding or removing a <th> column, always add or remove the matching <td> in the same commit. TypeScript does not detect thead/tbody column count mismatches. This caused an orphaned is_paid <td> (commit 5597150) after its <th> was already removed.
Address cell fallback rule: The address <td> must use event.location_address || event.location_address_zh || event.location_name. Never read a single field. Any locale-aware field displayed in admin must apply the same fallback chain as the corresponding helper in lib/types.ts.
Filter-option sync rule — closed sets: Any <select> filter whose options come from a closed canonical set (e.g. annotation_status, category) must list every value in that set as an <option>. When a new value is added to a TypeScript union, DB enum, or i18n file, the corresponding <option> element must be added in the same commit. TypeScript does not detect missing dropdown options.
Filter-option sync rule — open-ended sets: For sets that grow with new data (e.g. source_name, which gains a new value every time a scraper is added), never hardcode options. Instead, derive them from the loaded data:
Array.from(new Set(events.map(e => e.source_name))).sort()
A hardcoded source_name list will silently omit new scrapers and require a code change for every new source. This was fixed in commit fe1b39e after 10+ scrapers were added without appearing in the filter.
Filter display counts pattern: When a filter dropdown should show per-option counts (e.g. "電影 (12)"), derive them with useMemo([events]) from the already-loaded events state — no extra API call needed:
const categoryCounts = useMemo(() => {
const counts: Record<string, number> = {}
events.forEach(e => { counts[e.category] = (counts[e.category] ?? 0) + 1 })
return counts
}, [events])
This pattern applies to any <select> filter whose options map 1-to-1 with a field on the events array.
Annotation status label consistency rule: One status value = one i18n key, used consistently in all display surfaces: badge (getAnnotationLabel), filter dropdown <option>, any column header. Use the short-form keys: t("filterAnnotatedShort"), t("filterReviewedShort"), t("filterErrorShort"), t("filterPendingShort"). The long-form family (annotated, reviewed, error, pending) has been deleted — do not recreate it.
OCR-回填 array 欄位 sync rule: handleExtractFromImage 的 ARRAY_FIELDS = new Set([...]) 必須涵蓋所有 OCR Vision prompt 會回傳的陣列欄位。未列入集合的 array 會走 String(val) 分支被強轉成字串,污染 form state(型別變成字串而非 string[] | null),但 TypeScript 不會報錯(updateField 接 unknown)。新增任何 array OCR 欄位時,這四處必須同 commit 同步:
web/app/api/admin/extract-from-image/route.ts Vision prompt 加欄位定義(含視覺位置提示,如「海報底部 credit block」)
web/components/AdminEventTable.tsx ARRAY_FIELDS 集合擴增該欄位 key
web/lib/types.ts Event interface 欄位定義為 string[] | null
web/components/AdminEventForm.tsx 表單 state 初始值(避免新增欄位卻無 input UI)
Reference incident: 2026-05-26 — co_organizers / sponsors 三路徑 sync 同時補齊(commits 280fdc4 + e54b925)。
AdminSourcesTable.tsx — agent_category Sync Rule
web/components/AdminSourcesTable.tsx maintains a SOURCE_TYPE_LABELS map and a getFilteredSources function. Both must be updated whenever a new agent_category value is introduced in discovery_accounts.py:
SOURCE_TYPE_LABELS — add the new key: { ..., <new_category>: "<display label>" }
getFilteredSources — detect the new category by reading source.agent_category directly, NOT by hardcoded ID lists. Hardcoded ID lists silently omit newly discovered sources.
This is a paired-file rule: discovery_accounts.py (defines agent_category) ↔ AdminSourcesTable.tsx (displays it).
AdminSourcesTable.tsx — i18n Rule: No Hardcoded Labels
All visible text in AdminSourcesTable.tsx must use t(). Never hardcode Chinese/Japanese strings.
Required i18n keys (must exist in all three messages/*.json):
| Key | zh | en | ja |
|---|
sourcesFilterStatus | 狀態 | Status | ステータス |
sourcesFilterAll | 全部 | All | すべて |
sourcesFilterType | 來源分類 | Type | 分類 |
sourcesEditTypeMap | 編輯分類對照表 | Edit Type Map | 分類マップを編集 |
sourcesEditTypeMapTitle | 編輯分類對照表 | Edit Source Type Map | 分類マップを編集 |
Incident: 2026-05-01 — filter labels 狀態、全部、來源分類、編輯分類對照表 were hardcoded. Fixed in commit 4c30ab3.
Rule: Any new label added to AdminSourcesTable.tsx must immediately get a key in all three messages/ files. Use the Python json-module pattern for edits.
AdminSourcesTable.tsx — Filter Dropdown Count Pattern (typeCountMap)
When showing per-option counts in a filter <select> (e.g. "Peatix 主辦者 (5)"), the count must reflect:
- Apply all other active filters (e.g. status/
filter)
- Exclude the filter's own dimension (e.g. type/
selectedType)
This lets users see how many items they'd get if they switched to that option.
const typeCountMap = useMemo(() => {
const statusFiltered = sources.filter(s => {
if (filter === "implemented" && s.status !== "implemented") return false;
if (filter === "not-viable" && s.status !== "not-viable") return false;
if (filter === "candidate" && s.status !== "candidate") return false;
if (filter === "researched" && s.status !== "researched") return false;
if (filter === "recommended" && s.status !== "recommended") return false;
if (filter === "has_issue" && !s.github_issue_url) return false;
return true;
})
const counts: Record<string, number> = {}
statusFiltered.forEach(s => {
const type = effectiveTypeMap[s.id] ?? s.agent_category ?? "unknown"
counts[type] = (counts[type] ?? 0) + 1
})
return counts
}, [sources, filter, effectiveTypeMap])
Rule: Every multi-dimensional filter should use this "exclude self, apply others" approach. Never count from the fully-filtered result — it would always show N for the active option and mislead for inactive ones.
Sibling IIFE parity rule: AdminSourcesTable.tsx has two parallel count computations: typeCountMap (sources per type) and eventCountByType (active events per type). Both must apply the identical status-filter logic. When a new status value is added to the <select>, add its guard to ALL count IIFEs in the same commit. Adding to only one causes stale counts on the other.
Admin Tab Nav Sync Rule
All admin pages (web/app/[locale]/admin/*/page.tsx) share a manually duplicated tab nav bar. Every page must list the exact same tabs in the exact same order:
Events → Announcements → Reports → Stats → Quality → Research → Sources → Users → Creators → SEO-AEO
Current page renders as <span> (no link, visually distinct); all others render as <Link>.
When adding a new admin sub-page
- Add the tab link to all 10 existing admin pages in the same commit.
- Add the new page's own tab nav with the full set of tabs.
- Add
flex-wrap to the tab container if not already present.
- Add i18n key for the new tab label to all three
messages/*.json.
Verification command
grep -roh 'admin/[a-z_-]*' web/app/\[locale\]/admin/*/page.tsx | sort | uniq -c | sort -rn
Every route slug must appear the same number of times (= total number of admin pages). If any count differs, a page is missing that tab.
Incident: 2026-05-01 — announcements/page.tsx had only 5 tabs while admin main page had 10. Fixed in commit 1f37bb4.
Scraper Implementation
- Every new scraper source must extend
BaseScraper (scraper/sources/base.py) and implement scrape() → list[Event].
source_id must be stable across runs — it is the upsert dedup key. Use a deterministic hash or platform ID, never a timestamp.
raw_title and raw_description store original scraped text. Never overwrite them with translated or processed content.
- Date rules: follow the 4-tier cascade in
.github/skills/date-extraction/SKILL.md. Tier 4 (publish date fallback) fires only when tiers 1–3 all fail.
- Prepend
開催日時: YYYY年MM月DD日\n\n to raw_description whenever start_date is known.
- Annotator business-hours pre-extraction:
_extract_hours_from_raw() runs before GPT and before localized business_hours_* output. If raw text contains multiple explicit M月D日 HH:MM-HH:MM pairs across distinct days, preserve them as date-labelled multiline business_hours instead of returning the first bare time range. Bare single-range output is only safe when the hours apply uniformly to the whole event.
- Structured
raw_description extraction — prioritize content blocks over enumeration: Detail pages often contain navigation noise (navigation links, footers, breadcrumbs). Never enumerate <p> tags or slice first N elements to populate raw_description. Instead: (1) identify structural blocks (e.g., <table class="theater-detail">, <div class="detail-root">, or semantic headers) that contain event metadata, (2) extract each block as a labeled section (e.g., "作品紹介: ...", "料金: ...", "公式サイト: <url>"), (3) join with \n\n. This ensures annotator receives clean context rather than noisy text, preventing misclassification. Incident (2026-05-15): kawasaki_ac extracted first 8 <p> tags (mostly navigation), caused GPT to reject valid Taiwan film events as "unrelated".
- Address conflict pre-flight normalization (2026-06-02): Any scraper/oneoff logic that compares street numbers using regex (for example
\d+(?:-\d+)+) must normalize Unicode minus variants before matching. Minimum required transform: "−" -> "-" inside address normalization. Without this, venues pre-flight conflict checks can report false positives for the same location written with U+2212 in one source and ASCII hyphen in another.
- Series-type scraper pattern — coordinate date format support with field extraction: When expanding a scraper's date parsing to support new formats (e.g., adding
YYYY年M/D(土)~ support), simultaneously audit all related field extraction (pricing, times, location, organizer). Series-type sources often embed metadata in structured page sections (cinema event blocks, performance program tables) that depend on date parsing success to identify the correct block. If date parsing changes but pricing/times extraction doesn't, you risk incomplete or misaligned records. Pattern from kawasaki_ac (2026-05-15): supporting new date formats required also upgrading raw_description structure and extraction of business_hours, price_info, and official_url from the same detail page. Rule: When modifying a scraper's date logic, assume you must also update 3-5 other fields in the same commit.
- selection_reason is documentation, not a gate: The
selection_reason field records why an event was annotated (e.g., "Taiwan-related" vs "not selected"). It is not an enforcement mechanism. Three dangers: (1) a negative selection_reason written by annotator does NOT set is_active=false automatically, (2) upstream layers cannot rely on selection_reason to exclude bad data from downstream, (3) contradictions (e.g., is_active=true with selection_reason="not selected") silently propagate. To enforce exclusions, either: (a) have the scraper skip bad records entirely (preferred), (b) have annotator explicitly set is_active=false when rejecting, or (c) add consistency checks in DB with a CHECK constraint on (is_active, selection_reason) pairs. Do not rely on selection_reason text alone.
- New scraper checklist — all 4 steps required:
- Create
scraper/sources/<name>.py extending BaseScraper
- Register in
scraper/main.py → SCRAPERS (import + add instance)
- Insert a row in
research_sources with status='implemented', scraper_source_name=<key>, and a valid url. Skipping this causes researcher.py to re-report the source as a new candidate and triggers a ⚠️ WARNING in CI logs every day.
- Validate:
python main.py --dry-run --source <key> returns events cleanly
_warn_unregistered_scrapers() in main.py runs on every non-dry-run and emits a WARNING for any scraper key missing from research_sources. Check CI logs if you see ⚠️ scraper(s) NOT registered.
- Auto-QA via
event_reports queue (2026-05-01): New automated content-quality checks must write findings into event_reports with an auto_* prefix in report_types[] (e.g. auto_qa_simplified_zh, auto_qa_missing_address). Do NOT build a separate admin queue — the existing /admin/reports confirm/dismiss flow handles auto-findings unchanged. Always dedup against existing rows of the same auto_* type per event_id — check ALL statuses (pending, confirmed, dismissed), not just pending. A confirmed/dismissed report means the admin has already reviewed it; re-creating it undoes admin work. Also dedup within a single run via in-memory set. See scraper/auto_qa.py and engineer history.md 2026-05-01 / 2026-05-05.
Current QA_TYPES (as of 2026-06-03): auto_qa_simplified_zh, auto_qa_missing_address, auto_qa_missing_hours, auto_simplified_chinese, auto_qa_same_work_duplicate, auto_qa_performer_ai_translation_marker, auto_qa_performer_multi_value_pollution, auto_qa_performer_zh_equals_katakana, auto_qa_location_url_is_event_url.
- Admin dashboard count — use
head=True queries (2026-05-15, commit 518b5a8): Any summary/count card in admin pages must use .select('id', count='exact', head=True) rather than fetching rows and counting client-side. PostgREST silently truncates at max-rows=1000 regardless of .limit() — client-side count is always wrong for large tables. The head: true option fetches only the Content-Range header with the total count, returning no rows.
SIMP_RE / SC_ONLY / annotator._SIMP_TO_TRAD char addition rule (2026-05-01, updated 2026-05-11): Only add a char when its Traditional Chinese / Japanese form is a different glyph. Verify each candidate via CC-CEDICT or kanji.jitenon.jp before adding. Counter-example: 亮 is identical in Trad/Simp (照亮 is valid Trad) and triggered a false positive in production. When adding a new char, update all three simultaneously: annotator.py._SIMP_TO_TRAD, auto_qa.py.SIMP_RE, and auto_qa.py.SC_ONLY. Ensure SC_ONLY ⊆ _SIMP_TO_TRAD_RAW.keys() — detection must never find chars that fix cannot convert. See scraper-expert history.md 2026-05-01 and engineer history.md 2026-05-11.