From 9a638c8de28a00ea36447bd1bb768101d4dc3783 Mon Sep 17 00:00:00 2001 From: max747 Date: Wed, 1 Apr 2026 15:20:58 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=E8=A4=87=E6=95=B0=E3=82=BD=E3=83=BC?= =?UTF-8?q?=E3=82=B9=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SPEC.md | 26 ++++- admin/src/pages/EventFormPage.tsx | 167 +++++++++++++++++++++++++++++- admin/src/types/index.ts | 1 + lambda/aggregator/handler.py | 21 +++- lambda/aggregator/test_handler.py | 104 ++++++++++++++++++- viewer/src/types.ts | 1 + 6 files changed, 308 insertions(+), 12 deletions(-) diff --git a/SPEC.md b/SPEC.md index 3f3be4a..21f1ba4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -98,10 +98,11 @@ FGO イベント報告データを集計し、アイテムドロップ率を算 | `period.start` | string (ISO 8601) | イベント開始日時 | | `period.end` | string (ISO 8601) | イベント終了日時 | | `quests` | array | 集計対象クエストの一覧 | -| `quests[].questId` | string | Harvest 上のクエスト ID (ページ ID) | +| `quests[].questId` | string | Harvest 上のクエスト ID (ページ ID)。公開画面の URL キーおよび中間 JSON のファイルキーに使われる | | `quests[].name` | string | クエスト名 | | `quests[].level` | string | 推奨レベル | | `quests[].ap` | number | 消費 AP | +| `quests[].sourceQuestIds` | string[] (省略可) | 集計元の Harvest ページ ID リスト。複数ページに分割されているクエストを統合する際に指定する。省略または空配列の場合は `questId` のみを使用する | ### 3.2 除外リスト (`exclusions.json`) @@ -386,7 +387,28 @@ report_id: `605fc0f1` — `items` にイベントアイテム (ぐん肥/のび - しかし添字なしイベントアイテムはやはり集計に含めるべきでない(枠数として解釈できないため) - → 実数報告かどうかに関わらず、ベース名がイベントアイテムと一致するキーは常に除外する (→ 5.1 (b)) -### 7.7 同一報告者の複数報告 +### 7.7 Harvest ページ分割 (1:N ソースマッピング) + +同一クエストのデータが Harvest 上で複数のページ ID に分割されている場合がある(例: イベント期間途中でページが分割されたケース)。 + +このような場合は `events.json` の `quests[].sourceQuestIds` に全ページ ID を列挙することで対応する: + +```json +{ + "questId": "XCtBEoEwgr6R", + "name": "...", + "level": "90+", + "ap": 40, + "sourceQuestIds": ["XCtBEoEwgr6R", "Ab12CdEfGhIj"] +} +``` + +- 集計 Lambda は `sourceQuestIds` の各 ID から報告を取得し、重複排除後にマージして1つの中間 JSON を生成する +- 出力ファイルパスは `questId` を使用 (`{eventId}/{questId}.json`) +- 公開画面のルーティングや除外リストの管理は変わらない +- `sourceQuestIds` 省略または空配列の場合は `questId` のみを使用する(後方互換) + +### 7.8 同一報告者の複数報告 同一の `reporter_id` で複数の報告が存在する (例: 豆ぽ 5件、シェリル 6件)。これらは別々の周回期間の報告であり、それぞれ独立した報告として集計する。 diff --git a/admin/src/pages/EventFormPage.tsx b/admin/src/pages/EventFormPage.tsx index 4c9dc70..d744f3e 100644 --- a/admin/src/pages/EventFormPage.tsx +++ b/admin/src/pages/EventFormPage.tsx @@ -16,6 +16,10 @@ export function EventFormPage() { const [newItem, setNewItem] = useState(""); const [loading, setLoading] = useState(isEdit); const [error, setError] = useState(""); + const [sourceExpanded, setSourceExpanded] = useState>({}); + const [newSourceId, setNewSourceId] = useState>({}); + // 候補テーブルの「ソースに追加」select の選択状態: candidateId → questIndex (-1 = 未選択) + const [addAsSourceTarget, setAddAsSourceTarget] = useState>({}); // Harvest クエスト候補 const [candidates, setCandidates] = useState([]); @@ -80,7 +84,43 @@ export function EventFormPage() { }; const addQuest = () => { - setQuests([...quests, { questId: "", name: "", level: "", ap: 40 }]); + setQuests([...quests, { questId: "", name: "", level: "", ap: 40, sourceQuestIds: [] }]); + }; + + const toggleSourceExpanded = (index: number) => { + setSourceExpanded((prev) => ({ ...prev, [index]: !prev[index] })); + }; + + const addSourceId = (index: number) => { + const id = (newSourceId[index] ?? "").trim(); + if (!id) return; + const current = quests[index].sourceQuestIds ?? []; + if (current.includes(id)) return; + const updated = quests.map((q, i) => + i === index ? { ...q, sourceQuestIds: [...current, id] } : q, + ); + setQuests(updated); + setNewSourceId((prev) => ({ ...prev, [index]: "" })); + }; + + const removeSourceId = (questIndex: number, sourceIndex: number) => { + const current = quests[questIndex].sourceQuestIds ?? []; + const updated = quests.map((q, i) => + i === questIndex + ? { ...q, sourceQuestIds: current.filter((_, si) => si !== sourceIndex) } + : q, + ); + setQuests(updated); + }; + + const addAsSource = (questIndex: number, sourceId: string) => { + const current = quests[questIndex].sourceQuestIds ?? []; + if (current.includes(sourceId)) return; + const updated = quests.map((q, i) => + i === questIndex ? { ...q, sourceQuestIds: [...current, sourceId] } : q, + ); + setQuests(updated); + setAddAsSourceTarget((prev) => ({ ...prev, [sourceId]: -1 })); }; const removeQuest = (index: number) => { @@ -124,7 +164,7 @@ export function EventFormPage() { if (loading) return

読み込み中...

; - const addedIds = new Set(quests.map((q) => q.questId)); + const addedIds = new Set(quests.flatMap((q) => [q.questId, ...(q.sourceQuestIds ?? [])])); return (
@@ -276,9 +316,48 @@ export function EventFormPage() { {addedIds.has(c.id) ? ( 追加済み ) : ( - +
+ + {quests.length > 0 && ( + <> + + + + )} +
)} @@ -345,6 +424,84 @@ export function EventFormPage() { 削除
+ + {/* 複数ソース設定 */} +
+ + {sourceExpanded[i] && ( +
+

+ 集計元の Harvest ページ ID を列挙します。未設定の場合はクエスト ID + のみが使われます。 +

+ {(q.sourceQuestIds ?? []).map((sid, si) => ( +
+ {sid} + +
+ ))} +
+ setNewSourceId((prev) => ({ ...prev, [i]: e.target.value }))} + placeholder="Harvest ページ ID" + style={{ flex: 1, fontFamily: "monospace", fontSize: 13 }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addSourceId(i); + } + }} + /> + +
+
+ )} +
))} diff --git a/admin/src/types/index.ts b/admin/src/types/index.ts index e35aed3..98d5c24 100644 --- a/admin/src/types/index.ts +++ b/admin/src/types/index.ts @@ -3,6 +3,7 @@ export interface Quest { name: string; level: string; ap: number; + sourceQuestIds?: string[]; } export interface EventPeriod { diff --git a/lambda/aggregator/handler.py b/lambda/aggregator/handler.py index 5084723..6fd5b27 100644 --- a/lambda/aggregator/handler.py +++ b/lambda/aggregator/handler.py @@ -161,10 +161,23 @@ def fetch_harvest_reports(quest_id: str) -> list[dict]: def process_quest(event_id: str, quest: dict, event_items: set[str]) -> None: """クエスト1件を処理: 取得・変換・中間 JSON 出力。""" quest_id = quest["questId"] - logger.info("Processing quest %s (%s)", quest_id, quest["name"]) - - reports = fetch_harvest_reports(quest_id) - logger.info("Fetched %d reports for quest %s", len(reports), quest_id) + source_ids = quest.get("sourceQuestIds") or [quest_id] + logger.info("Processing quest %s (%s), sources: %s", quest_id, quest["name"], source_ids) + + all_reports: list[dict] = [] + seen_ids: set[str] = set() + for sid in source_ids: + fetched = fetch_harvest_reports(sid) + logger.info("Fetched %d reports from source %s", len(fetched), sid) + for r in fetched: + rid = r.get("id", r.get("report_id", "")) + if rid and rid in seen_ids: + continue + seen_ids.add(rid) + all_reports.append(r) + + reports = all_reports + logger.info("Total %d unique reports for quest %s", len(reports), quest_id) if not event_items: event_items = detect_event_items(reports) diff --git a/lambda/aggregator/test_handler.py b/lambda/aggregator/test_handler.py index 9803cd9..21d4b1d 100644 --- a/lambda/aggregator/test_handler.py +++ b/lambda/aggregator/test_handler.py @@ -10,7 +10,9 @@ sys.modules["botocore"] = MagicMock() sys.modules["botocore.exceptions"] = MagicMock() -from handler import detect_event_items, is_raw_count_report, transform_report # noqa: E402 +from unittest.mock import patch + +from handler import detect_event_items, is_raw_count_report, process_quest, transform_report # noqa: E402 # --- detect_event_items --- @@ -155,3 +157,103 @@ def test_nan_becomes_none(self): report = _make_report({"素材A": "NaN"}) result, _ = transform_report(report, set()) assert result["素材A"] is None + + +# --- process_quest (sourceQuestIds) --- + + +def _make_harvest_report(rid: str, items: dict[str, str]) -> dict: + return { + "id": rid, + "reporter": "u1", + "reporter_name": "user1", + "runcount": 10, + "timestamp": "2026-01-01T00:00:00+09:00", + "note": "", + "items": items, + } + + +class TestProcessQuestSourceQuestIds: + """process_quest の sourceQuestIds 対応""" + + def _run(self, quest: dict, fetch_side_effect: list[list]) -> dict: + """process_quest を実行し、write_json に渡された引数を返す。""" + with ( + patch("handler.fetch_harvest_reports", side_effect=fetch_side_effect), + patch("handler.write_json") as mock_write, + ): + process_quest("ev1", quest, set()) + _key, output = mock_write.call_args[0] + return output + + def test_single_source_backward_compat(self): + """sourceQuestIds 未設定 → questId のみ取得""" + quest = {"questId": "AAA", "name": "Q1", "level": "90+", "ap": 40} + reports_a = [_make_harvest_report("r1", {"素材A": "5"})] + output = self._run(quest, [reports_a]) + assert len(output["reports"]) == 1 + assert output["reports"][0]["id"] == "r1" + + def test_multiple_sources_merged(self): + """sourceQuestIds 設定 → 全ソースのレポートがマージされる""" + quest = { + "questId": "AAA", + "name": "Q1", + "level": "90+", + "ap": 40, + "sourceQuestIds": ["AAA", "BBB"], + } + reports_a = [_make_harvest_report("r1", {"素材A": "5"})] + reports_b = [_make_harvest_report("r2", {"素材A": "3"})] + output = self._run(quest, [reports_a, reports_b]) + assert len(output["reports"]) == 2 + ids = {r["id"] for r in output["reports"]} + assert ids == {"r1", "r2"} + + def test_duplicate_report_ids_deduplicated(self): + """同一レポート ID が複数ソースに存在する場合は重複排除される""" + quest = { + "questId": "AAA", + "name": "Q1", + "level": "90+", + "ap": 40, + "sourceQuestIds": ["AAA", "BBB"], + } + reports_a = [_make_harvest_report("r1", {"素材A": "5"})] + reports_b = [_make_harvest_report("r1", {"素材A": "5"})] # 同じ ID + output = self._run(quest, [reports_a, reports_b]) + assert len(output["reports"]) == 1 + assert output["reports"][0]["id"] == "r1" + + def test_empty_source_quest_ids_fallback(self): + """sourceQuestIds が空リストの場合 questId にフォールバック""" + quest = { + "questId": "AAA", + "name": "Q1", + "level": "90+", + "ap": 40, + "sourceQuestIds": [], + } + reports_a = [_make_harvest_report("r1", {"素材A": "5"})] + output = self._run(quest, [reports_a]) + assert len(output["reports"]) == 1 + + def test_output_key_uses_quest_id(self): + """出力 S3 キーは常に questId を使う""" + quest = { + "questId": "AAA", + "name": "Q1", + "level": "90+", + "ap": 40, + "sourceQuestIds": ["AAA", "BBB"], + } + reports_a = [_make_harvest_report("r1", {"素材A": "5"})] + reports_b = [_make_harvest_report("r2", {"素材A": "3"})] + with ( + patch("handler.fetch_harvest_reports", side_effect=[reports_a, reports_b]), + patch("handler.write_json") as mock_write, + ): + process_quest("ev1", quest, set()) + key, _output = mock_write.call_args[0] + assert key == "ev1/AAA.json" diff --git a/viewer/src/types.ts b/viewer/src/types.ts index 420df5d..ae7d107 100644 --- a/viewer/src/types.ts +++ b/viewer/src/types.ts @@ -3,6 +3,7 @@ export interface Quest { name: string; level: string; ap: number; + sourceQuestIds?: string[]; } export interface EventPeriod { From f52bb383978cf73d7380be0cfa443c8abc7ac424 Mon Sep 17 00:00:00 2001 From: max747 Date: Wed, 1 Apr 2026 15:28:58 +0900 Subject: [PATCH 2/3] =?UTF-8?q?seen=5Fids=20=E3=81=B8=E3=81=AE=E7=A9=BA?= =?UTF-8?q?=E6=96=87=E5=AD=97=E8=BF=BD=E5=8A=A0=E3=82=92=E9=98=B2=E3=81=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rid が空文字(falsy)の場合は seen_ids に追加しないよう修正。 重複排除の意図が明確になり、将来のロジック変更時の誤動作を防ぐ。 Co-Authored-By: Claude Sonnet 4.6 --- lambda/aggregator/handler.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lambda/aggregator/handler.py b/lambda/aggregator/handler.py index 6fd5b27..e0a3fe5 100644 --- a/lambda/aggregator/handler.py +++ b/lambda/aggregator/handler.py @@ -171,9 +171,10 @@ def process_quest(event_id: str, quest: dict, event_items: set[str]) -> None: logger.info("Fetched %d reports from source %s", len(fetched), sid) for r in fetched: rid = r.get("id", r.get("report_id", "")) - if rid and rid in seen_ids: - continue - seen_ids.add(rid) + if rid: + if rid in seen_ids: + continue + seen_ids.add(rid) all_reports.append(r) reports = all_reports From 10988fe0d063cf3e65fce688ee8309d470b0ebf4 Mon Sep 17 00:00:00 2001 From: max747 Date: Wed, 1 Apr 2026 15:30:06 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=E3=82=AF=E3=82=A8=E3=82=B9=E3=83=88?= =?UTF-8?q?=E5=89=8A=E9=99=A4=E6=99=82=E3=81=AB=E8=A4=87=E6=95=B0=E3=82=BD?= =?UTF-8?q?=E3=83=BC=E3=82=B9=E9=96=A2=E9=80=A3=20state=20=E3=81=AE?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=83=87=E3=83=83=E3=82=AF=E3=82=B9=E3=81=9A?= =?UTF-8?q?=E3=82=8C=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sourceExpanded / newSourceId / addAsSourceTarget が配列インデックスを キーに持つため、クエスト削除でインデックスがずれる問題を修正。 removeQuest でインデックスを詰め直すことで対応。 また addAsSource に範囲外インデックスのガードを追加。 Co-Authored-By: Claude Sonnet 4.6 --- admin/src/pages/EventFormPage.tsx | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/admin/src/pages/EventFormPage.tsx b/admin/src/pages/EventFormPage.tsx index d744f3e..c9e2ed8 100644 --- a/admin/src/pages/EventFormPage.tsx +++ b/admin/src/pages/EventFormPage.tsx @@ -114,6 +114,7 @@ export function EventFormPage() { }; const addAsSource = (questIndex: number, sourceId: string) => { + if (questIndex < 0 || questIndex >= quests.length) return; const current = quests[questIndex].sourceQuestIds ?? []; if (current.includes(sourceId)) return; const updated = quests.map((q, i) => @@ -125,6 +126,33 @@ export function EventFormPage() { const removeQuest = (index: number) => { setQuests(quests.filter((_, i) => i !== index)); + setSourceExpanded((prev) => { + const next: Record = {}; + for (const [k, v] of Object.entries(prev)) { + const ki = Number(k); + if (ki < index) next[ki] = v; + else if (ki > index) next[ki - 1] = v; + } + return next; + }); + setNewSourceId((prev) => { + const next: Record = {}; + for (const [k, v] of Object.entries(prev)) { + const ki = Number(k); + if (ki < index) next[ki] = v; + else if (ki > index) next[ki - 1] = v; + } + return next; + }); + setAddAsSourceTarget((prev) => { + const next: Record = {}; + for (const [k, v] of Object.entries(prev)) { + if (v < index) next[k] = v; + else if (v > index) next[k] = v - 1; + // v === index: 削除されたクエストを指していたので除外(未選択に戻す) + } + return next; + }); }; const updateQuest = (index: number, field: keyof Quest, value: string | number) => {