一键导入
engineer
Implementation rules for database migrations, Python scrapers, and Next.js web for the Engineer agent
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Implementation rules for database migrations, Python scrapers, and Next.js web for the Engineer agent
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Planning principles, model selection, and scope rules for the Architect agent
BaseScraper contract, field rules, and Peatix-specific conventions for the Scraper Expert agent
Scraper test execution rules, output validation criteria, and report format for the Tester agent
BaseScraper contract, field rules, and Peatix-specific conventions for the Scraper Expert agent
Platform rules and field mappings for the 誠品生活日本橋 (eslite spectrum Nihonbashi) scraper
版元ドットコム Playwright scraper for Taiwan-related books (Publication Intel v3.1)
| name | engineer |
| description | Implementation rules for database migrations, Python scrapers, and Next.js web for the Engineer agent |
| applyTo | .github/agents/engineer.agent.md |
Read this at the start of every session before touching any code.
NEVER write to
.github/skills/agents/engineer/— that path has been deleted. The canonical location is:.github/skills/agents/engineer/SKILL.mdand.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>/.
triage first, auto-fix second. Never mix high-risk remediation into the triage stage.eslite_spectrum, keep the rule set conservative and do not fold promotional talk events into the same batch.event_form == ['publication']; never infer pure from books_media, source name, or title prefix.location_address, location_address_zh, location_address_en, business_hours, business_hours_zh, business_hours_en, location_prefectures.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.organizer) remains required for pure publication rows; missing publisher stays a QA finding.['publication', 'lecture']) are physical events and must not inherit pure skip behavior.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.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.official_url is present, and prefix publication titles with [新刊出版] across locale variants. NDL magazine and journal rows should use bracketed periodical labels like [期刊專文].scraper/database.py::_VALID_EVENT_FORMS and its tests updated whenever publication handling changes.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.handoffs: at creation time. An agent with no handoffs leaves users stranded after task completion.[attr='x'].class = same element (no space). [attr='x'] .class = descendant combinator (space = different element).data-preserve-theme and group on the same <Link>), never insert a space between them.[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.line-height, padding, and element height in Safari after any shared inline-flex / rounded-full refactor.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.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.-ml-4) instead of zeroing the padding. Keep the component's native px-4 py-2 hit area.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.res.ok / error and return a non-2xx status with a readable detail.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.{ error, detail } on the server, append detail to the frontend message, and add the i18n key to all three messages/*.json simultaneously.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.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。/ja/announcements), start a local Next server first (npm run dev or configured webServer) and verify port 3000 is reachable.net::ERR_CONNECTION_REFUSED as an execution-environment failure first, not an application regression.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.--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.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)。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.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).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./etc/zprofile if [ -z "$LANG" ]; then export LANG=C.UTF-8; fi, triggered when VS Code launches from Dock/Finder (does not inherit LANG).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.C.UTF-8 (PEP 540) so python-only checks look clean — test BSD tools instead.$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.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 → 無待推送變更,無事可做。新增/修改密鑰掃描時三層必須一致,且都是「降低風險」非 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 |
.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)同一份語意,改一處要三處同步。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 須失敗。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 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([
/* 與 web/lib/types.ts CATEGORIES 同步 */
]);
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 條件。
SELECT table_name FROM information_schema.tables WHERE table_name = 'X';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).{ count: 'exact', head: true } を使う。 .limit(n) + client-side filter でカウントを計算すると PostgREST の max-rows=1000 silent truncation により 1,000 件超で常に過小表示になる。カウントのみ必要な場合は行データを取得しない head-count クエリを使う:
// ❌ client-side count — PostgREST silently truncates at 1000
const { data } = await supabase.from("events").select("id").eq("is_active", true);
const count = data?.length ?? 0;
// ✅ head-count — 正確な COUNT、行データ不要
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).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 path
{"success": True, "duration_seconds": int(time.time() - start)}
# except block
{"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).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)。
if (!payload.source_url) { delete payload.source_url; }(讓 update 不動該欄位、保留 canonical;填 fallback 會用較差 URL 覆蓋 /ja/events/{id})。EventIntakeWizard 以 context="owner"|"admin" 分派 —— owner → owner-events.ts、admin → admin-events.ts(sanitizeForm 用 ?? SITE_URL 永不 null,本就安全)。只改一端=沒改。text[] 欄位,write 前必 split 成陣列。 AdminEventForm 的 co_organizers/sponsors 是純 text <input>(值型別雖標 string[] | null,實際綁逗號字串),DB 欄位卻是 text[];直接寫字串 → malformed array literal: "A, B, C",阻斷 owner 發佈(f080fd5, 2026-07-04)。
.split(/[,,、]/).map(s => s.trim()).filter(Boolean)(含全形逗號 ,、頓號 、,貼合日文輸入),空陣列 → null。owner 端封裝為 owner-events.ts::parseCommaArray + COMMA_SEPARATED_ARRAY_FIELDS。AdminEditClient 提交前已 .split(","))本就對,owner(sanitizeOwnerForm)漏了 → 只在 owner UGC 觸發。改任一端欄位正規化,grep 另一端是否對稱。events 表 + field_corrections(recordFieldCorrections)。後者若存原始字串,未來 annotator 套回 text[] 會再度 malformed,且字串 vs 陣列比較每次誤判為「變更」而灌 FC。凡「表單字串 → 陣列欄位」修復,events 寫入 + FC 記錄兩面一起改。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:
GRANT SELECT ON ... TO anon, authenticated, service_role;GRANT SELECT, INSERT, UPDATE, DELETE ON ... TO authenticated, service_role;GRANT SELECT, INSERT, UPDATE, DELETE ON ... TO service_role;
Always ALTER TABLE ... ENABLE ROW LEVEL SECURITY before adding GRANTs.scraper_runs・research_reports は scraper/annotator が service_role で INSERT するため service_role INSERT が必要。creators は admin UI から authenticated が CRUD するため authenticated CRUD が必要。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)。任何 client-side supabase.from(T).update(...).eq("id", x) 在以下三種情況都會回傳 error: null + 空 data,supabase-js 不會丟錯:
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 {
// success path
}
已知需套用的呼叫點(截至 2026-05-15):
web/components/IsActiveToggle.tsx handleToggleweb/components/AdminEditClient.tsx handleSaveweb/components/AdminEventTable.tsx handleToggleActive / handleBulkToggleActive / handleBulkForceRescrape / category bulk update / handleSaveWorkweb/components/AdminCreatorsClient.tsx toggleActiveweb/components/AdminReportsTable.tsx 所有 bulk-confirm 路徑替代設計:高風險寫入改走 server action / route handler 用 service role key,繞過 client JWT 過期問題。
任何含 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 UI 操作的表,cmd 必須涵蓋 SELECT + UPDATE 至少。
admin mutation handler は必ずペアで同じ実装パターンを使う。
confirmReport が server action ならば dismissReport も server action でなければならない。片方だけ昇格させると、残った方が 0-row silent success トラップに落ちる(2026-05-15 handleDismiss 事例)。
チェックリスト(新規 admin mutation を追加する前に確認):
.select("id") + data.length === 0 guard + return { ok: false, error } パターンを必ず含める。result.ok を確認し、false の場合 alert(result.error) で即時フィードバックを表示する。// web/app/actions/dismiss-report.ts — server action テンプレート
"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 };
}
会場・組織の公式サイト有無を curl で確認する際は apex ドメインと www. サブドメインの両方を必ず試すこと。
# ❌ apex のみ確認 — 日本サイトは www なしを DNS に登録しないことが多い
curl -s --max-time 5 -o /dev/null -w "%{http_code}" "https://example.jp/" # → 000 (DNS 失敗)
# ✅ 両方確認
curl -s --max-time 5 -o /dev/null -w "%{http_code}" "https://example.jp/" # 000
curl -s --max-time 5 -o /dev/null -w "%{http_code}" "https://www.example.jp/" # 200 ← 正解
000 = curl が DNS 解決に失敗した = 「その変形が存在しない」であり「サイト全体が存在しない」ではない2xx が返るまで apex / www / 短縮ドメインを試し、すべて失敗した場合のみ「公式サイトなし」と判断するcoconeri.jp → 000、www.coconeri.jp → 200(2026-05-17、event eeb5b12e)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()
# その後 python annotator.py を実行
出版來源の追加規則:
ndl_opensearch / hanmoto / kawade_rss は scraper/annotator.py::_PUBLICATION_SOURCES と source SKILL の出版模板規則を同時に同期する。片方だけ更新すると「規範有、程式碼漏」が再発する。eslite_spectrum は混合來源なので同じ batch に混ぜない。is_active=true かつ annotation_status='error' を基本にし、field_corrections が 1 件でもあるイベントは丸ごと skip する。人工修正済みイベントを再注入しないため。047_add_broadcast_event_form.sql を反映済みか確認する。未反映だと event_form=['publication'] の書き戻しが events_event_form_check で失敗する。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 表示になる。annotation_status だけでは不十分。対象 source の active rows が期待する event_form に収斂していることを DB query で確認し、前台で raw key が出ないところまで確認する。注意事項:
daily_report.py は .limit(5) でエラー件数を表示するため、実際の件数と一致しない。COUNT で別途確認すること。raw_description(1行のみ)の sub-event も、親イベントのコンテキストを参照することで正常アノテーション可能。annotator.py の GPT JSON パースロジックに問題がある可能性もある(レスポンス形式の変化等)。後台任何「觸發外部 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 卡在「標注中,請稍候…」。
規則:
// client-side:呼叫可能耗時 >5s 的 API 一律加 AbortSignal
const res = await fetch("/api/admin/annotate-event", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ eventId }),
signal: AbortSignal.timeout(58000), // 58s hard cap
});
// API route 端:外部 AI API(OpenAI 等)也必須加 timeout
const openaiRes = await fetch("https://api.openai.com/v1/chat/completions", {
...
signal: AbortSignal.timeout(25000), // 防止超過 Vercel maxDuration
});
已修復(commit 77fc092,2026-05-15):
AdminEventTable.tsx handleSaveAndAnnotate 的 annotate-event fetchannotate-event/route.ts OpenAI callAdminEventTable.tsx handlePublish 補 .select("id") + 0-row guardClient 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。0s 開始,應在下一次 action 開始時設定,不要在 effect 的 idle branch 內 reset。Reference incident: 2026-06-04 OwnerCreateClient.tsx targeted lint FAIL.
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)。
規則:
anon INSERT を許可していても、ブラウザ直接 INSERT はハング耐性がない。暫定防護(Server Action 化が間に合わないとき): withClientTimeout(promise, ms, label) helper で supabase.from(...).insert/.update() を包む。Promise.race + setTimeout で hard cap reject → catch 分岐が必ず発動 → setSaving(false) 復帰。
完了済み 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); },
);
});
}
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; // GPT 値で上書き
}
これは SELECT 句で fetch したフィールドにしか効かない。SELECT 漏れ = undefined = 空判定 = サイレント上書き。
規則:
extractionFields 配列に列挙したフィールドは すべて L258 の SELECT 句に含める。extractionFields 配列(L437 付近)alwaysOverwriteFields(description 系のみ)extractionFields の各 key が SELECT 句にあるか目視確認。Reference incident: 2026-05-20 — end_date が extractionFields にあるのに SELECT に無く、ユーザーが画面で選択した end_date が毎回 GPT 幻覚(例:2023-10-14)で上書きされていた(commit e0a5ea8)。
catch ブロックが確実に発動する。// ❌ Client Component での直接 INSERT — ハング時に永久 loading
const supabase = createClient(); // browser client
const { error } = await supabase.from("event_reports").insert({ ... }); // may hang silently
// ✅ Server Action 経由 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)
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) するとログイン直後に未ログイン表示へ戻る(フリッカー)。
// ❌ ブラウザ client のイベント種別を真実源にする — 偽 SIGNED_OUT でログアウト化
supabase.auth.onAuthStateChange((event) => {
if (event === "SIGNED_OUT") { setUser(null); return; }
void loadMe();
});
// ✅ 常にサーバ (/api/me の getUser()) を真実源として再検証し、
// サーバが user なしと返したときのみクリアする
supabase.auth.onAuthStateChange(() => { void loadMe(); });
// loadMe() は fetch("/api/me", { cache: "no-store" }) → setUser(data?.user ?? null)
② <Link> が route handler(副作用を伴う GET)を指す場合は必ず prefetch={false}。
Next.js の <Link> はホバーで対象 URL を prefetch する。対象が route handler だと実 GET が飛ぶ。ログアウトリンク(/auth/logout → supabase.auth.signOut())を <Link> で置くと、メニュー内で隣の項目へマウスを動かしただけでホバー prefetch が signOut() を実行し、ユーザーが勝手にログアウトする。
<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 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:
ALTER PUBLICATION supabase_realtime ADD TABLE <table>;Diagnosis: Supabase Dashboard → Database → Replication — confirm the target table appears in the supabase_realtime publication list.
Frontend subscription rules:
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.useEffect return: supabase.removeChannel(channel).fetchRow(id) helper when both INSERT and UPDATE handlers need to re-fetch the full row (avoids duplicating the SELECT clause).// ✅ 正確:在 useEffect 內部建立 client,避免 stale closure
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); };
}, []);
// ❌ 錯誤:在頂層建立 client 再傳入 effect(stale closure 風險)
const supabase = createClient();
useEffect(() => {
const channel = supabase.channel(...); // 可能捕捉到舊實例
}, []);
Server Component + Realtime 分離模式:
Server Component 中的動態 UI(badge、計數器)如果需要即時性,必須拆出為 Client Component:
// AdminTabNav.tsx(Server Component)
const pending = await getPendingCount();
return <AdminReportsBadge initialCount={pending} />; // Client Component
// AdminReportsBadge.tsx(Client Component)
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).
// ✅ Subscribe to both INSERT and UPDATE for admin pages
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();
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)。
// ❌ mutation 後只 push — 頁面仍顯示舊資料
await saveToDb(data);
router.push('/admin');
// ✅ 先 refresh 再 push — 強制 SSR 頁面重新執行 Server Component
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 が競合し画面破損 |
dict → tuple), immediately smoke-test before committing: python -c "from module import fn; print(type(fn(...)))"getattr(obj, 'attr', default) when reading an attribute that may not exist on all subclasses.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.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."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.autoInstrumentServerFunctions: false — it silently disables server-side error capture.sourcemaps: { disable: !process.env.SENTRY_AUTH_TOKEN }.所有 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。
// ❌ 未包 try/catch — uncaught exception 回傳 HTML, Safari 拋 SyntaxError
export async function POST(request: Request) {
const data = await request.formData(); // 若此行拋例外 → 回傳 HTML 500
return NextResponse.json({ url: "..." });
}
// ✅ 包整個函式體
export async function POST(request: Request) {
try {
const data = await request.formData();
// … all logic …
return NextResponse.json({ url: "..." });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Server error";
return NextResponse.json({ error: message }, { status: 500 });
}
}
配套規則:
const rawExt = file.name.split(".").pop() ?? "jpg";
const ext = rawExt.replace(/[^a-zA-Z0-9]/g, "").slice(0, 10) || "jpg";
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)。
事件卡片在本專案有兩條獨立渲染路徑,修改前必須同時檢查:
| 路徑 | 檔案 | 用途 |
|---|---|---|
| 共用元件 | 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。getCityLabel、日期格式、地址解析等純函式)抽至 web/lib/<name>.ts,兩處 import;切忌在兩處複製貼上同樣邏輯。curl https://tokyotaiwanradar.com/zh | grep -c '<新元素 class>',期望非 0。Reference: 2026-05-05 城市徽章兩輪才生效(commit 5a29c13 → 9f4b468)。
web/app/[locale]/events/[id]/page.tsx 的 performer 顯示邏輯必須依照以下優先序:
getEventPerformer(event, locale)(多語言欄位優先)performers[].join("、")(日文多人列表 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)。
| 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]:
performerperformers[] 為空/nullperformers 陣列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 を生成_oneoff_migrate_multi_performer.py --execute で実行する(--dry-run で事前確認)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:
PascalCase name is treated as a component → must live at module top level (or be exported). Never declare it inside another component body.<Tag />) must use camelCase and be invoked as a plain function call: {eventLink(event)}, not <EventLink event={event} />.useMemo / useCallback to derive the JSX, then render {memoizedNode} directly.// ❌ Fails react-hooks/static-components
export default function Page() {
function SectionHeader({ title }) { return <h2>{title}</h2>; }
function eventLink(e) { return <a href={...}>{e.name}</a>; } // PascalCase trap if renamed
return <SectionHeader title="..." />;
}
// ✅ Component at module top level
function SectionHeader({ title }: { title: string }) { return <h2>{title}</h2>; }
export default function Page() {
return <SectionHeader title="..." />;
}
// ✅ camelCase helper invoked as function call
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.
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 模組 |
// ❌ Edge runtime 拋錯
const res = await fetch(url, { next: { revalidate: 86400 } });
// ✅ 純 Web API,正確
const res = await fetch(url);
Google Fonts text API 子集化:
?text=... 參數只取頁面用到的字元,大幅縮小 ArrayBuffer 體積。src: 與 url( 之間可能有空白;regex 必須用 /src:\s*url\(/,不可寫死 /src: url\(/。// ❌ 若 CSS 為 "src: url(..." 就會 miss
css.match(/src: url\(https:\/\//);
// ✅ 容許任意空白
css.match(/src:\s*url\(https:\/\//);
When adding a new bulk operation that operates on a derived value from selected events (e.g. common categories, common source, common status):
useMemo([selected, events]) — never inline in renderuseState(false)) guarding the async handlerPromise.all(selectedEvents.map(...)) for parallel DB updates — do NOT loop with await sequentiallysetEvents() after Promise.all resolvesException — 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.
// ✅ sequential — safe when handleConfirm calls setSaving internally
for (const id of selectedIds) {
await handleConfirm(id);
}
// ❌ parallel — causes setSaving race if handleConfirm has internal state updates
await Promise.all([...selectedIds].map(id => handleConfirm(id)));
When a list row is currently a <button> and you need to add a checkbox:
<button> — violates HTML spec (interactive element inside button)<button className="w-full ..."> → <div className="flex items-stretch"> + <label> checkbox </label> + <button className="flex-1 ...">onClick={(e) => e.stopPropagation()} to prevent bubble triggering row expand/collapse// ✅ correct structure
<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>
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.
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.
The bulk action bar includes a "新增分類" row with a multi-select category picker (same CATEGORY_GROUPS layout as inline editor). Implementation:
bulkAddCatPending: Set<string> tracks pending selections, bulkAddCatOpen: boolean toggles dropdownnew Set([...prevCategory, ...catsToAdd]) — never duplicatescategory_corrections for AI feedback loop (same as single-event category edit)selected Set on success (same as bulk remove)⚠️ 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")t("bulkConfirmSelected", { count })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.
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:
location_name containing 「オンライン」 (online events) — .not("location_name", "ilike", "%オンライン%")location_name containing 「電視頻道」 (TV channel events)source_name = 'gguide_tv' (TV guide source)location_name containing 〒 (address already embedded in name) — .not("location_name", "like", "%〒%")location_name containing ・ (multi-city events — location_name uses ・-joined city names by convention; no single address exists)東京, 香港, 岡山, 文京区 — 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.
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.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 狀態才能正確過濾。
// ✅ 正確:切換 past 時預填 to=today
} 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:
# ❌ fails in zsh
git add web/app/[locale]/page.tsx
# ✅ correct
git add 'web/app/[locale]/page.tsx'
Applies to all shell operations: git add, cp, mv, cat, etc.
When touching any implementation or docs related to --create-issue and GITHUB_TOKEN, enforce one canonical wording only:
Issues: write + Metadata: readrepo scopeIn the same change set, sync all related layers:
scraper/update_source.pydocs/GITHUB_TOKEN_SYNC_CHECKLIST.md.github/instructions/token-rotation.instructions.md.github/agents/researcher.agent.md.github/SECRETS_LIFECYCLE.mdNever leave non-standard Issues permission wording (e.g. the &-separated form) in any tracked file.
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.github_pat_xxx), never real values.Canonical source of truth: web/lib/types.ts → Category union type, CATEGORIES array, CATEGORY_GROUPS array.
Update all three message files simultaneously:
web/messages/zh.json — key under categories.*web/messages/en.json — same keyweb/messages/ja.json — same keyFor group labels: keys are group_arts, group_lifestyle, group_knowledge, group_society, group_archive.
Update all 6 locations in a single commit — do NOT split across commits:
web/lib/types.ts — Category union typeweb/lib/types.ts — CATEGORIES flat arrayweb/lib/types.ts — CATEGORY_GROUPS (place in the correct group)web/messages/zh.json — label under categories.*web/messages/en.json — same keyweb/messages/ja.json — same keyCATEGORY_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.
| 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.tsxrenders category tags on the homepage card — display-only, no picker. Label renames propagate automatically.
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:
grid-cols-[4.5rem_1fr] per group row — col 1 = group label (right-aligned, shrink-0), col 2 = flex-wrap tagsflex-wrap with a mixed label+tags row — overflows cause label misalignment when a group has many itemsWhenever this file is modified for any reason, verify these 3 lines are intact before committing:
{t("name")} — tFilters namespace does NOT exist; using tFilters("search") silently renders the raw key string{t("category")} — same reason, do NOT use tFilters("category")bg-white — do NOT revert to bg-gray-50These 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])
// Render: `${label}${count > 0 ? ` (${count})` : ''}`
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 集合擴增該欄位 keyweb/lib/types.ts Event interface 欄位定義為 string[] | nullweb/components/AdminEventForm.tsx 表單 state 初始值(避免新增欄位卻無 input UI)Reference incident: 2026-05-26 — co_organizers / sponsors 三路徑 sync 同時補齊(commits 280fdc4 + e54b925)。
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).
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.
When showing per-option counts in a filter <select> (e.g. "Peatix 主辦者 (5)"), the count must reflect:
filter)selectedType)This lets users see how many items they'd get if they switched to that option.
const typeCountMap = useMemo(() => {
// Apply status filter only — do NOT apply selectedType
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])
// Render option:
// count > 0 → `${label} (${count})`
// count === 0 → `${label}` (no parentheses, avoid UI noise)
// "all" option → never show count (semantic total, `filtered.length` shown elsewhere)
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.
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>.
flex-wrap to the tab container if not already present.messages/*.json.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.
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..github/skills/date-extraction/SKILL.md. Tier 4 (publish date fallback) fires only when tiers 1–3 all fail.開催日時: YYYY年MM月DD日\n\n to raw_description whenever start_date is known._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.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".\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.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 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.scraper/sources/<name>.py extending BaseScraperscraper/main.py → SCRAPERS (import + add instance)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.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.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.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.