Add a script to fetch and display KBO team rankings and records.
- KBO 공식 홈페이지에서 연도별 팀 순위 및 상대 전적 데이터를 가져오는 크롤링 스크립트를 구현했습니다. - ASP.NET의 ViewState 및 EventValidation 필드를 처리하여 동적인 연도 전환 요청을 지원합니다. - 데이터를 가독성 높은 콘솔 테이블 형태로 출력하거나 JSON 형식으로 변환하는 기능을 제공합니다.
This commit is contained in:
parent
05b774a713
commit
20e3b1cbf0
1
graphify-out/.graphify_python
Normal file
1
graphify-out/.graphify_python
Normal file
@ -0,0 +1 @@
|
|||||||
|
C:\Users\윤정민\AppData\Roaming\uv\tools\graphifyy\Scripts\python.exe
|
||||||
151
graphify-out/GRAPH_REPORT.md
Normal file
151
graphify-out/GRAPH_REPORT.md
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
`# Graph Report - docs/data-patterns (2026-05-29)
|
||||||
|
|
||||||
|
## Corpus Check
|
||||||
|
- Corpus is ~13,070 words - fits in a single context window. You may not need a graph.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- 173 nodes · 177 edges · 28 communities (12 shown, 16 thin omitted)
|
||||||
|
- Extraction: 97% EXTRACTED · 3% INFERRED · 0% AMBIGUOUS · INFERRED: 5 edges (avg confidence: 0.91)
|
||||||
|
- Token cost: 154,966 input · 0 output
|
||||||
|
|
||||||
|
## Community Hubs (Navigation)
|
||||||
|
- [[_COMMUNITY_ReadWrite Optimization Fixes|Read/Write Optimization Fixes]]
|
||||||
|
- [[_COMMUNITY_Archive & Stats Pipeline|Archive & Stats Pipeline]]
|
||||||
|
- [[_COMMUNITY_Scoreboard & User Write Fixes|Scoreboard & User Write Fixes]]
|
||||||
|
- [[_COMMUNITY_Client User Auth & Profile|Client User Auth & Profile]]
|
||||||
|
- [[_COMMUNITY_Server Cron & Game Sync|Server Cron & Game Sync]]
|
||||||
|
- [[_COMMUNITY_Client KBO Data (RankPlayer)|Client KBO Data (Rank/Player)]]
|
||||||
|
- [[_COMMUNITY_Client KBO Schedule & GameDetail|Client KBO Schedule & GameDetail]]
|
||||||
|
- [[_COMMUNITY_Prediction Vote Flow|Prediction Vote Flow]]
|
||||||
|
- [[_COMMUNITY_Server Vote & RTDB Layer|Server Vote & RTDB Layer]]
|
||||||
|
- [[_COMMUNITY_Game Trigger & Stats Invalidation|Game Trigger & Stats Invalidation]]
|
||||||
|
- [[_COMMUNITY_Attendance & Notification|Attendance & Notification]]
|
||||||
|
- [[_COMMUNITY_Attendance Service (Server)|Attendance Service (Server)]]
|
||||||
|
- [[_COMMUNITY_kboCache & Lock|kboCache & Lock]]
|
||||||
|
- [[_COMMUNITY_User Profile Service|User Profile Service]]
|
||||||
|
- [[_COMMUNITY_Scoreboard Cache|Scoreboard Cache]]
|
||||||
|
- [[_COMMUNITY_Nickname Reservation|Nickname Reservation]]
|
||||||
|
- [[_COMMUNITY_voteHistory Subcollection|voteHistory Subcollection]]
|
||||||
|
- [[_COMMUNITY_pointLedger Subcollection|pointLedger Subcollection]]
|
||||||
|
- [[_COMMUNITY_Firebase Auth (Client)|Firebase Auth (Client)]]
|
||||||
|
- [[_COMMUNITY_Remote Config (Client)|Remote Config (Client)]]
|
||||||
|
- [[_COMMUNITY_Firestore Trigger|Firestore Trigger]]
|
||||||
|
- [[_COMMUNITY_rankSnapshot Service|rankSnapshot Service]]
|
||||||
|
- [[_COMMUNITY_KBO External API|KBO External API]]
|
||||||
|
- [[_COMMUNITY_MemCache (In-Memory)|MemCache (In-Memory)]]
|
||||||
|
- [[_COMMUNITY_RTDB userVotes Path|RTDB userVotes Path]]
|
||||||
|
- [[_COMMUNITY_RTDB scoreboardCache|RTDB scoreboardCache]]
|
||||||
|
- [[_COMMUNITY_Client Prediction Provider|Client Prediction Provider]]
|
||||||
|
- [[_COMMUNITY_Stats Cache (RTDB)|Stats Cache (RTDB)]]
|
||||||
|
|
||||||
|
## God Nodes (most connected - your core abstractions)
|
||||||
|
1. `userService (Server)` - 7 edges
|
||||||
|
2. `dailyArchive Cron (03:00 KST)` - 7 edges
|
||||||
|
3. `UserRepository (keepAlive)` - 7 edges
|
||||||
|
4. `kboRepository (Server)` - 6 edges
|
||||||
|
5. `dailyArchive.ts (setDay + judgeDay)` - 6 edges
|
||||||
|
6. `Firestore users Collection` - 5 edges
|
||||||
|
7. `Firestore games Collection` - 5 edges
|
||||||
|
8. `RTDB /cache/stats/{uid} Path` - 5 edges
|
||||||
|
9. `scoreboardService (Server)` - 5 edges
|
||||||
|
10. `gameSyncService (Server)` - 5 edges
|
||||||
|
|
||||||
|
## Surprising Connections (you probably didn't know these)
|
||||||
|
- `RTDB: votes/{gameId}/counts (public read)` --semantically_similar_to--> `RTDB: voteAggregates/{gameId} (public read)` [INFERRED] [semantically similar]
|
||||||
|
graphify-out/tmp-corpus/backend_optimization-plan.md → graphify-out/tmp-corpus/client_improvements-client.md
|
||||||
|
- `dailyArchive Cron (03:00 KST)` --writes_to--> `Firestore users Collection` [EXTRACTED]
|
||||||
|
graphify-out/tmp-corpus/backend_backend-writes.md → graphify-out/tmp-corpus/backend_backend-reads.md
|
||||||
|
- `Client FCM / users.fcmTokens Direct Write` --writes_to--> `Firestore users Collection` [EXTRACTED]
|
||||||
|
graphify-out/tmp-corpus/backend_data-lifecycle.md → graphify-out/tmp-corpus/backend_backend-reads.md
|
||||||
|
- `POST /admin/game/end Endpoint (markGameEnded)` --writes_to--> `Firestore games Collection` [EXTRACTED]
|
||||||
|
graphify-out/tmp-corpus/backend_backend-writes.md → graphify-out/tmp-corpus/backend_backend-reads.md
|
||||||
|
- `gameResultService (Server)` --reads_from--> `RTDB /votes/{gameId} Path` [EXTRACTED]
|
||||||
|
graphify-out/tmp-corpus/backend_backend-writes.md → graphify-out/tmp-corpus/backend_backend-reads.md
|
||||||
|
|
||||||
|
## Hyperedges (group relationships)
|
||||||
|
- **KBO External Fetch to Cache to Client Endpoint Pipeline** — backend_reads_external_kbo, backend_reads_firestore_kbocache, backend_reads_repo_kbocacherepo, backend_reads_firestore_kbolocks, backend_lifecycle_concept_distributedlock [EXTRACTED 1.00]
|
||||||
|
- **dailyArchive Cron Write Pipeline** — backend_writes_cron_dailyarchive, backend_reads_svc_judgmentservice, backend_reads_firestore_votehistory, backend_reads_firestore_users, backend_reads_svc_ranksnapshotservice, backend_reads_rtdb_scoreboardcache, backend_reads_rtdb_cachestats [EXTRACTED 1.00]
|
||||||
|
- **onGameCompleted Fan-out Write Pattern** — backend_writes_trigger_ongamecompleted, backend_writes_svc_gameresultservice, backend_reads_rtdb_uservotes, backend_reads_rtdb_cachestats, backend_lifecycle_concept_fanout [EXTRACTED 1.00]
|
||||||
|
- **Backend Read Optimization Set R1 R4 R5 R7** — backend_improvements_reads_r1_gamedaycache, backend_improvements_reads_r4_backoff, backend_improvements_reads_r5_fieldmask, backend_improvements_reads_r7_parallelrank [EXTRACTED 1.00]
|
||||||
|
- **Backend Write Optimization Set W1 through W6** — backend_improvements_writes_w1_diff, backend_improvements_writes_w2_votehistory, backend_improvements_writes_w3_statsdedup, backend_improvements_writes_w4_ranksnapshot, backend_improvements_writes_w5r6_photourl, backend_improvements_writes_w6_gamedetailttl [EXTRACTED 1.00]
|
||||||
|
- **User Prediction Vote Flow** — backend_writes_endpoint_post_prediction, backend_writes_endpoint_put_prediction, backend_reads_repo_voterepo, backend_reads_rtdb_votes, backend_reads_rtdb_uservotes [EXTRACTED 1.00]
|
||||||
|
- **Tier 1 High-Impact Low-Risk Optimizations** — opt_W1, opt_W2, opt_R1, opt_C1 [EXTRACTED 1.00]
|
||||||
|
- **Tier 2 Medium-Impact Optimizations** — opt_R2, opt_W3, opt_R3, opt_R4, opt_C2, opt_C3 [EXTRACTED 1.00]
|
||||||
|
- **Tier 3 Small / Housekeeping Optimizations** — opt_W4, opt_W5, opt_W6, opt_R5, opt_R6, opt_R7, opt_C4 [EXTRACTED 1.00]
|
||||||
|
- **Applied Client Optimizations (opt/client-rw branch)** — opt_C1, opt_C2, opt_C3, opt_C4, client_vote_repo_after, client_standings_provider_after, client_h2h_provider_after, client_ace_pitcher_after, client_current_user_after [EXTRACTED 1.00]
|
||||||
|
- **SWR Cache Adapters (4 domains)** — client_schedule_cache, client_game_detail_cache, client_day_record_cache, client_game_record_dates_cache [EXTRACTED 1.00]
|
||||||
|
- **All Client Repositories (keepAlive)** — client_game_repo, client_user_repo, client_prediction_repo, client_team_record_repo, client_player_repo, client_game_detail_repo, client_scoreboard_repo, client_attendance_repo [EXTRACTED 1.00]
|
||||||
|
- **Duplicate CF Call Pattern (C2+C3)** — client_provider_h2h, client_provider_rank, client_ep_rank, client_provider_ace_pitcher, client_provider_pitchers, client_ep_player [EXTRACTED 1.00]
|
||||||
|
|
||||||
|
## Communities (28 total, 16 thin omitted)
|
||||||
|
|
||||||
|
### Community 0 - "Read/Write Optimization Fixes"
|
||||||
|
Cohesion: 0.10
|
||||||
|
Nodes (22): R4: Cache Stampede Exponential Backoff Fix, R7: Multi-Year Rank Parallel Fetch Fix, W1: games syncGamesForMonth getAll+diff Fix, W6: game_detail Cache Bulk Invalidation Stopped, Client SWR Cache (swr_cache.dart), Distributed Lock (kboLocks TTL 30s), GET /kbo/gameDetail Endpoint, GET /kbo/player Endpoint (+14 more)
|
||||||
|
|
||||||
|
### Community 1 - "Archive & Stats Pipeline"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (18): R1: GameDayCache Memoization Fix, W2: voteHistory Double-Write Consolidation Fix, W3: Archive Stats Invalidation Fan-out Dedup Fix, Fan-out Write Pattern (stats invalidation), GET /prediction/games Endpoint, GET /stats Endpoint, Firestore games Collection, Firestore voteHistory Subcollection (+10 more)
|
||||||
|
|
||||||
|
### Community 2 - "Scoreboard & User Write Fixes"
|
||||||
|
Cohesion: 0.13
|
||||||
|
Nodes (16): R5: Scoreboard Self-User FieldMask Fix, W4: Rank Snapshot Merged Into Judgment Tx Fix, W5+R6: getMe photoUrl Hot-Write Suppression Fix, Client FCM / users.fcmTokens Direct Write, Precompute Scoreboard Cache Concept, GET /prediction/scoreboard Endpoint, Firestore users Collection, RTDB /nicknames Path (+8 more)
|
||||||
|
|
||||||
|
### Community 3 - "Client User Auth & Profile"
|
||||||
|
Cohesion: 0.14
|
||||||
|
Nodes (16): currentUser Provider After C4 (keepAlive), CF Endpoint: GET /user/check-nickname, CF Endpoint: DELETE /user, CF Endpoint: GET /user, CF Endpoint: PATCH /user (profile update), CF Endpoint: POST /user (onboarding), CF Endpoint: GET /prediction/scoreboard, CF Endpoint: GET /stats (+8 more)
|
||||||
|
|
||||||
|
### Community 4 - "Server Cron & Game Sync"
|
||||||
|
Cohesion: 0.16
|
||||||
|
Nodes (15): Cron Job: 02:00 daily (syncGamesForMonth + kboCache invalidation), dailyArchive.ts (setDay + judgeDay), debugHandlers.ts (Unauthenticated forceSync/dailyArchive), syncGamesForMonth (gameSyncService.ts:63), Firestore: dailyArchive collection, Firestore: games collection, Firestore/Cache: kboCache (game_detail__ TTL), R1: dailyArchive listByDate Sharing (+7 more)
|
||||||
|
|
||||||
|
### Community 5 - "Client KBO Data (Rank/Player)"
|
||||||
|
Cohesion: 0.16
|
||||||
|
Nodes (14): acePitcher Provider After C3 (derived from pitchers), CF Endpoint: GET /kbo/player, CF Endpoint: GET /kbo/rank, headToHead Provider After C2 (watches standings), Client Improvements Summary (C1-C4 Applied), PlayerRepository (keepAlive), acePitcher Provider (autoDispose, duplicate player call), h2h Provider (autoDispose, no cache, duplicates rank call) (+6 more)
|
||||||
|
|
||||||
|
### Community 6 - "Client KBO Schedule & GameDetail"
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (13): DayRecordCache (day_prediction_record, skip if settled), CF Endpoint: GET /kbo/gameDetail, CF Endpoint: GET /kbo/schedule, GameDetailCache (game_detail namespace), GameDetailRepository (keepAlive), GameRecordDatesCache (game_record_dates namespace), GameRepository (keepAlive), DayPredictionRecord Provider (autoDispose + SWR skip if settled) (+5 more)
|
||||||
|
|
||||||
|
### Community 7 - "Prediction Vote Flow"
|
||||||
|
Cohesion: 0.22
|
||||||
|
Nodes (11): CF Endpoint: GET /prediction (by date), CF Endpoint: POST /prediction, CF Endpoint: PUT /prediction, CF Endpoint: GET /prediction/summary (10s polling), PredictionRepository (keepAlive), PredictionAction Notifier (keepAlive), voteSummary Provider (StreamProvider 10s poll), VoteRepository After C1 (RTDB onValue subscription) (+3 more)
|
||||||
|
|
||||||
|
### Community 8 - "Server Vote & RTDB Layer"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (10): GET /kbo/games Endpoint, GET /prediction Endpoint, GET /prediction/summary Endpoint, In-Memory MemCache (Server-side), voteRepository (Server), RTDB /votes/{gameId} Path, gameListService (Server), predictionService (Server) (+2 more)
|
||||||
|
|
||||||
|
### Community 9 - "Game Trigger & Stats Invalidation"
|
||||||
|
Cohesion: 0.22
|
||||||
|
Nodes (10): gameResultService.ts:42 (stats fan-out on game complete), CF: /stats (getStats Cloud Function), invalidateStats (/cache/stats/{uid} delete), judgeDay (judgmentService.ts:94), onGameCompleted.ts:59 (Firestore trigger), Firestore/Cache: /cache/stats/{uid}, Firestore: voteHistory collection, R2: getStats Incremental Aggregation (+2 more)
|
||||||
|
|
||||||
|
### Community 10 - "Attendance & Notification"
|
||||||
|
Cohesion: 0.40
|
||||||
|
Nodes (5): AttendanceRepository (keepAlive), CF Endpoint: GET /attendance/month, CF Endpoint: POST /attendance/check-in, CF Endpoint: PATCH /users/me/notifications, CheckInController Notifier (autoDispose)
|
||||||
|
|
||||||
|
### Community 11 - "Attendance Service (Server)"
|
||||||
|
Cohesion: 0.50
|
||||||
|
Nodes (4): Firestore attendance Subcollection, Firestore pointLedger Subcollection, attendanceService (Server), POST /attendance/check-in Endpoint
|
||||||
|
|
||||||
|
## Knowledge Gaps
|
||||||
|
- **78 isolated node(s):** `Firestore attendance Subcollection`, `Firestore pointLedger Subcollection`, `RTDB /nicknames Path`, `RTDB /userNicknames Path`, `scoreboardCacheRepository (Server)` (+73 more)
|
||||||
|
These have ≤1 connection - possible missing edges or undocumented components.
|
||||||
|
- **16 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||||
|
|
||||||
|
## Suggested Questions
|
||||||
|
_Questions this graph is uniquely positioned to answer:_
|
||||||
|
|
||||||
|
- **Why does `C1: voteSummary Polling to RTDB Subscription` connect `Prediction Vote Flow` to `Server Cron & Game Sync`, `Client KBO Data (Rank/Player)`?**
|
||||||
|
_High betweenness centrality (0.090) - this node is a cross-community bridge._
|
||||||
|
- **Why does `Client Improvements Summary (C1-C4 Applied)` connect `Client KBO Data (Rank/Player)` to `Client User Auth & Profile`, `Prediction Vote Flow`?**
|
||||||
|
_High betweenness centrality (0.088) - this node is a cross-community bridge._
|
||||||
|
- **Why does `Cost Summary: Top Write/Read/Client Costs` connect `Server Cron & Game Sync` to `Prediction Vote Flow`?**
|
||||||
|
_High betweenness centrality (0.070) - this node is a cross-community bridge._
|
||||||
|
- **What connects `Firestore attendance Subcollection`, `Firestore pointLedger Subcollection`, `RTDB /nicknames Path` to the rest of the system?**
|
||||||
|
_78 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||||
|
- **Should `Read/Write Optimization Fixes` be split into smaller, more focused modules?**
|
||||||
|
_Cohesion score 0.1038961038961039 - nodes in this community are weakly interconnected._
|
||||||
|
- **Should `Scoreboard & User Write Fixes` be split into smaller, more focused modules?**
|
||||||
|
_Cohesion score 0.13333333333333333 - nodes in this community are weakly interconnected._
|
||||||
|
- **Should `Client User Auth & Profile` be split into smaller, more focused modules?**
|
||||||
|
_Cohesion score 0.14166666666666666 - nodes in this community are weakly interconnected._
|
||||||
@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "readme_overview", "label": "Panit Data Pattern Documentation Overview", "file_type": "document", "source_file": "graphify-out/tmp-corpus/backend_README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "readme_cost_summary", "label": "Cost Summary: Top Write/Read/Client Costs", "file_type": "document", "source_file": "graphify-out/tmp-corpus/backend_README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "readme_index_ok", "label": "Firestore Composite Indexes (No Gaps)", "file_type": "document", "source_file": "graphify-out/tmp-corpus/backend_README.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "readme_overview", "target": "readme_cost_summary", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_README.md", "source_location": null, "weight": 1.0}, {"source": "readme_overview", "target": "readme_index_ok", "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_README.md", "source_location": null, "weight": 1.0}, {"source": "readme_cost_summary", "target": "opt_W1", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_README.md", "source_location": null, "weight": 1.0}, {"source": "readme_cost_summary", "target": "opt_R1", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_README.md", "source_location": null, "weight": 1.0}, {"source": "readme_cost_summary", "target": "opt_C1", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_README.md", "source_location": null, "weight": 1.0}], "hyperedges": []}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "backend_lifecycle_client_firebaseauth", "label": "Client Firebase Auth", "file_type": "document", "source_file": "graphify-out/tmp-corpus/backend_data-lifecycle.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "backend_lifecycle_client_remoteconfig", "label": "Client Remote Config (team_order_by_standing)", "file_type": "document", "source_file": "graphify-out/tmp-corpus/backend_data-lifecycle.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "backend_lifecycle_client_fcm", "label": "Client FCM / users.fcmTokens Direct Write", "file_type": "document", "source_file": "graphify-out/tmp-corpus/backend_data-lifecycle.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "backend_lifecycle_client_swrcache", "label": "Client SWR Cache (swr_cache.dart)", "file_type": "code", "source_file": "graphify-out/tmp-corpus/backend_data-lifecycle.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "backend_lifecycle_concept_fanout", "label": "Fan-out Write Pattern (stats invalidation)", "file_type": "document", "source_file": "graphify-out/tmp-corpus/backend_data-lifecycle.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "backend_lifecycle_concept_precompute", "label": "Precompute Scoreboard Cache Concept", "file_type": "document", "source_file": "graphify-out/tmp-corpus/backend_data-lifecycle.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "backend_lifecycle_concept_distributedlock", "label": "Distributed Lock (kboLocks TTL 30s)", "file_type": "document", "source_file": "graphify-out/tmp-corpus/backend_data-lifecycle.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "backend_lifecycle_client_fcm", "target": "backend_reads_firestore_users", "relation": "writes_to", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_data-lifecycle.md", "source_location": "fcm_service.dart:40,58", "weight": 1.0}, {"source": "backend_reads_rtdb_scoreboardcache", "target": "backend_lifecycle_concept_precompute", "relation": "conceptually_related_to", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_data-lifecycle.md", "source_location": null, "weight": 0.8}, {"source": "backend_reads_firestore_kbolocks", "target": "backend_lifecycle_concept_distributedlock", "relation": "conceptually_related_to", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_data-lifecycle.md", "source_location": null, "weight": 0.8}, {"source": "backend_writes_svc_gameresultservice", "target": "backend_lifecycle_concept_fanout", "relation": "conceptually_related_to", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_data-lifecycle.md", "source_location": null, "weight": 0.8}, {"source": "backend_lifecycle_client_swrcache", "target": "backend_reads_endpoint_kbo_schedule", "relation": "caches", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_data-lifecycle.md", "source_location": "swr_cache.dart", "weight": 1.0}, {"source": "backend_lifecycle_client_swrcache", "target": "backend_reads_endpoint_kbo_gamedetail", "relation": "caches", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_data-lifecycle.md", "source_location": "swr_cache.dart", "weight": 1.0}], "hyperedges": []}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "backend_improvements_writes_w1_diff", "label": "W1: games syncGamesForMonth getAll+diff Fix", "file_type": "document", "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-writes.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "backend_improvements_writes_w2_votehistory", "label": "W2: voteHistory Double-Write Consolidation Fix", "file_type": "document", "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-writes.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "backend_improvements_writes_w3_statsdedup", "label": "W3: Archive Stats Invalidation Fan-out Dedup Fix", "file_type": "document", "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-writes.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "backend_improvements_writes_w4_ranksnapshot", "label": "W4: Rank Snapshot Merged Into Judgment Tx Fix", "file_type": "document", "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-writes.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "backend_improvements_writes_w5r6_photourl", "label": "W5+R6: getMe photoUrl Hot-Write Suppression Fix", "file_type": "document", "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-writes.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "backend_improvements_writes_w6_gamedetailttl", "label": "W6: game_detail Cache Bulk Invalidation Stopped", "file_type": "document", "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-writes.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "backend_improvements_writes_w1_diff", "target": "backend_reads_svc_gamesyncservice", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-writes.md", "source_location": "commit b17ac4c", "weight": 1.0}, {"source": "backend_improvements_writes_w2_votehistory", "target": "backend_reads_firestore_votehistory", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-writes.md", "source_location": "commit 48a8f16", "weight": 1.0}, {"source": "backend_improvements_writes_w3_statsdedup", "target": "backend_reads_rtdb_cachestats", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-writes.md", "source_location": "commit 9a59755", "weight": 1.0}, {"source": "backend_improvements_writes_w4_ranksnapshot", "target": "backend_reads_svc_ranksnapshotservice", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-writes.md", "source_location": "commit 4ea1a77", "weight": 1.0}, {"source": "backend_improvements_writes_w5r6_photourl", "target": "backend_reads_svc_userservice", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-writes.md", "source_location": "commit 037baab", "weight": 1.0}, {"source": "backend_improvements_writes_w6_gamedetailttl", "target": "backend_reads_svc_gamedetailservice", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-writes.md", "source_location": "commit 50349e6", "weight": 1.0}], "hyperedges": [{"id": "backend_write_optimization_set", "label": "Backend Write Optimization Set W1 through W6", "nodes": ["backend_improvements_writes_w1_diff", "backend_improvements_writes_w2_votehistory", "backend_improvements_writes_w3_statsdedup", "backend_improvements_writes_w4_ranksnapshot", "backend_improvements_writes_w5r6_photourl", "backend_improvements_writes_w6_gamedetailttl"], "relation": "form", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-writes.md"}]}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "backend_improvements_reads_r1_gamedaycache", "label": "R1: GameDayCache Memoization Fix", "file_type": "document", "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-reads.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "backend_improvements_reads_r4_backoff", "label": "R4: Cache Stampede Exponential Backoff Fix", "file_type": "document", "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-reads.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "backend_improvements_reads_r5_fieldmask", "label": "R5: Scoreboard Self-User FieldMask Fix", "file_type": "document", "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-reads.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}, {"id": "backend_improvements_reads_r7_parallelrank", "label": "R7: Multi-Year Rank Parallel Fetch Fix", "file_type": "document", "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-reads.md", "source_location": null, "source_url": null, "captured_at": null, "author": null, "contributor": null}], "edges": [{"source": "backend_improvements_reads_r1_gamedaycache", "target": "backend_writes_cron_dailyarchive", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-reads.md", "source_location": "commit 5a498c3", "weight": 1.0}, {"source": "backend_improvements_reads_r1_gamedaycache", "target": "backend_reads_repo_gamerepo", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-reads.md", "source_location": "src/repositories/gameRepository.ts", "weight": 1.0}, {"source": "backend_improvements_reads_r4_backoff", "target": "backend_reads_repo_kbocacherepo", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-reads.md", "source_location": "commit 69f31ae", "weight": 1.0}, {"source": "backend_improvements_reads_r5_fieldmask", "target": "backend_reads_svc_scoreboardservice", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-reads.md", "source_location": "commit 60461c4", "weight": 1.0}, {"source": "backend_improvements_reads_r7_parallelrank", "target": "backend_reads_repo_kborepo", "relation": "rationale_for", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-reads.md", "source_location": "commit e9ba36c", "weight": 1.0}], "hyperedges": [{"id": "backend_read_optimization_set", "label": "Backend Read Optimization Set R1 R4 R5 R7", "nodes": ["backend_improvements_reads_r1_gamedaycache", "backend_improvements_reads_r4_backoff", "backend_improvements_reads_r5_fieldmask", "backend_improvements_reads_r7_parallelrank"], "relation": "form", "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "graphify-out/tmp-corpus/backend_improvements-backend-reads.md"}]}
|
||||||
File diff suppressed because one or more lines are too long
1
graphify-out/cache/stat-index.json
vendored
Normal file
1
graphify-out/cache/stat-index.json
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"D:\\Projects\\WebstormProjects\\mmday-firebase\\graphify-out\\tmp-corpus\\backend_backend-reads.md":{"size":16226,"mtime_ns":1779955343720489300,"hash":"be162108974f5941b103bc78da76154d3fc5e6a8ccc31b9c47c38eb62b562b93"},"D:\\Projects\\WebstormProjects\\mmday-firebase\\graphify-out\\tmp-corpus\\backend_backend-writes.md":{"size":16769,"mtime_ns":1779955314703817500,"hash":"982e2ae7171fbeced1d621713764def17a7c62d43afbc2c33ec61bc24f4b2c46"},"D:\\Projects\\WebstormProjects\\mmday-firebase\\graphify-out\\tmp-corpus\\backend_data-lifecycle.md":{"size":7444,"mtime_ns":1779955497181792200,"hash":"7629b9506707c9b5159dc65a70d82c1892c7423714bc34072bebaa1d732b6bad"},"D:\\Projects\\WebstormProjects\\mmday-firebase\\graphify-out\\tmp-corpus\\backend_improvements-backend-reads.md":{"size":19137,"mtime_ns":1779965419403981600,"hash":"c1e2be8cd496b2793a8ea06cce799bf6e6216201df02b6e9d9df1920919bcbe3"},"D:\\Projects\\WebstormProjects\\mmday-firebase\\graphify-out\\tmp-corpus\\backend_improvements-backend-writes.md":{"size":27290,"mtime_ns":1779965451961931900,"hash":"a461767f44048b941099ef1e762c6f74b2e6d80130fd99c55a661e052ea1010d"},"D:\\Projects\\WebstormProjects\\mmday-firebase\\graphify-out\\tmp-corpus\\backend_optimization-plan.md":{"size":5975,"mtime_ns":1779955541015705800,"hash":"ebee6bb0067bafc39c67b1de39ada6b1f03f1a3cd0acef3975cd27c01b09b725"},"D:\\Projects\\WebstormProjects\\mmday-firebase\\graphify-out\\tmp-corpus\\backend_README.md":{"size":2060,"mtime_ns":1779963250725354100,"hash":"441055f47c3a0f3527431cd91c584675a6993b5a5c9beb2e50f73e2473b5a540"},"D:\\Projects\\WebstormProjects\\mmday-firebase\\graphify-out\\tmp-corpus\\client_client-patterns.md":{"size":16783,"mtime_ns":1779955399418784100,"hash":"8cfe82a177e2713dd0053806c245802876fb1886d003508a2a04eaf72c5360d2"},"D:\\Projects\\WebstormProjects\\mmday-firebase\\graphify-out\\tmp-corpus\\client_improvements-client.md":{"size":12480,"mtime_ns":1779965406311097500,"hash":"c1d818f03b5b27b69ccda49673068289c47fe61ea91ee89f2177f8bc6dea7795"}}
|
||||||
12
graphify-out/cost.json
Normal file
12
graphify-out/cost.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"date": "2026-05-28T23:28:44.360304+00:00",
|
||||||
|
"input_tokens": 154966,
|
||||||
|
"output_tokens": 0,
|
||||||
|
"files": 9
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_input_tokens": 154966,
|
||||||
|
"total_output_tokens": 0
|
||||||
|
}
|
||||||
305
graphify-out/graph.html
Normal file
305
graphify-out/graph.html
Normal file
File diff suppressed because one or more lines are too long
4446
graphify-out/graph.json
Normal file
4446
graphify-out/graph.json
Normal file
File diff suppressed because it is too large
Load Diff
47
graphify-out/manifest.json
Normal file
47
graphify-out/manifest.json
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"D:\\Projects\\WebstormProjects\\mmday-firebase\\graphify-out\\tmp-corpus\\backend_backend-reads.md": {
|
||||||
|
"mtime": 1779955343.7204893,
|
||||||
|
"ast_hash": "47f71e4a003d0d15af710aa3e03da080",
|
||||||
|
"semantic_hash": "47f71e4a003d0d15af710aa3e03da080"
|
||||||
|
},
|
||||||
|
"D:\\Projects\\WebstormProjects\\mmday-firebase\\graphify-out\\tmp-corpus\\backend_backend-writes.md": {
|
||||||
|
"mtime": 1779955314.7038176,
|
||||||
|
"ast_hash": "5713e28108683534b14505db87fec39d",
|
||||||
|
"semantic_hash": "5713e28108683534b14505db87fec39d"
|
||||||
|
},
|
||||||
|
"D:\\Projects\\WebstormProjects\\mmday-firebase\\graphify-out\\tmp-corpus\\backend_data-lifecycle.md": {
|
||||||
|
"mtime": 1779955497.1817923,
|
||||||
|
"ast_hash": "86efbf284e4bde74aab40d70cb78239b",
|
||||||
|
"semantic_hash": "86efbf284e4bde74aab40d70cb78239b"
|
||||||
|
},
|
||||||
|
"D:\\Projects\\WebstormProjects\\mmday-firebase\\graphify-out\\tmp-corpus\\backend_improvements-backend-reads.md": {
|
||||||
|
"mtime": 1779965419.4039817,
|
||||||
|
"ast_hash": "11de86bdc094d3bef7f541035b11c5fd",
|
||||||
|
"semantic_hash": "11de86bdc094d3bef7f541035b11c5fd"
|
||||||
|
},
|
||||||
|
"D:\\Projects\\WebstormProjects\\mmday-firebase\\graphify-out\\tmp-corpus\\backend_improvements-backend-writes.md": {
|
||||||
|
"mtime": 1779965451.961932,
|
||||||
|
"ast_hash": "3711a459366acd98e5d77b9cf3923462",
|
||||||
|
"semantic_hash": "3711a459366acd98e5d77b9cf3923462"
|
||||||
|
},
|
||||||
|
"D:\\Projects\\WebstormProjects\\mmday-firebase\\graphify-out\\tmp-corpus\\backend_optimization-plan.md": {
|
||||||
|
"mtime": 1779955541.0157058,
|
||||||
|
"ast_hash": "8d171256079b75bcebc4622a4ae637ec",
|
||||||
|
"semantic_hash": "8d171256079b75bcebc4622a4ae637ec"
|
||||||
|
},
|
||||||
|
"D:\\Projects\\WebstormProjects\\mmday-firebase\\graphify-out\\tmp-corpus\\backend_README.md": {
|
||||||
|
"mtime": 1779963250.7253542,
|
||||||
|
"ast_hash": "b385dc63bc5a1895cd403d96f262b9a8",
|
||||||
|
"semantic_hash": "b385dc63bc5a1895cd403d96f262b9a8"
|
||||||
|
},
|
||||||
|
"D:\\Projects\\WebstormProjects\\mmday-firebase\\graphify-out\\tmp-corpus\\client_client-patterns.md": {
|
||||||
|
"mtime": 1779955399.4187841,
|
||||||
|
"ast_hash": "df76664a0705ebcb69d1468e28d234e8",
|
||||||
|
"semantic_hash": "df76664a0705ebcb69d1468e28d234e8"
|
||||||
|
},
|
||||||
|
"D:\\Projects\\WebstormProjects\\mmday-firebase\\graphify-out\\tmp-corpus\\client_improvements-client.md": {
|
||||||
|
"mtime": 1779965406.3110974,
|
||||||
|
"ast_hash": "17a330c9c9dba5e369ae0a3e6eb1dd56",
|
||||||
|
"semantic_hash": "17a330c9c9dba5e369ae0a3e6eb1dd56"
|
||||||
|
}
|
||||||
|
}
|
||||||
462
src/kbo-team-rank.ts
Normal file
462
src/kbo-team-rank.ts
Normal file
@ -0,0 +1,462 @@
|
|||||||
|
/**
|
||||||
|
* KBO 팀 순위 조회
|
||||||
|
* Usage: npx tsx kbo-team-rank.ts [연도] [연도2] ...
|
||||||
|
* 예시: npx tsx kbo-team-rank.ts 2024 2025
|
||||||
|
* npx tsx kbo-team-rank.ts (기본: 현재 연도)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE_URL =
|
||||||
|
"https://www.koreabaseball.com/Record/TeamRank/TeamRank.aspx";
|
||||||
|
|
||||||
|
const PREFIX = "ctl00$ctl00$ctl00$cphContents$cphContents$cphContents$";
|
||||||
|
|
||||||
|
// ── HTML 파싱 헬퍼 ──
|
||||||
|
|
||||||
|
function extractHiddenField(html: string, name: string): string {
|
||||||
|
// id="__VIEWSTATE" value="..."
|
||||||
|
const re = new RegExp(`id="${name}"[^>]*value="([^"]*)"`, "i");
|
||||||
|
const match = html.match(re);
|
||||||
|
if (match) return match[1];
|
||||||
|
// fallback: name="..." value="..."
|
||||||
|
const re2 = new RegExp(`name="${name}"[^>]*value="([^"]*)"`, "i");
|
||||||
|
const match2 = html.match(re2);
|
||||||
|
return match2?.[1] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTags(html: string): string {
|
||||||
|
return html.replace(/<[^>]*>/g, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntities(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 테이블 파싱 ──
|
||||||
|
|
||||||
|
interface TeamRank {
|
||||||
|
순위: string;
|
||||||
|
팀명: string;
|
||||||
|
경기: string;
|
||||||
|
승: string;
|
||||||
|
패: string;
|
||||||
|
무: string;
|
||||||
|
승률: string;
|
||||||
|
게임차: string;
|
||||||
|
최근10경기: string;
|
||||||
|
연속: string;
|
||||||
|
홈: string;
|
||||||
|
방문: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WinLossDraw = [win: number, loss: number, draw: number];
|
||||||
|
|
||||||
|
interface TeamVsRecord {
|
||||||
|
팀명: string;
|
||||||
|
상대전적: Record<string, WinLossDraw>;
|
||||||
|
합계: WinLossDraw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRankTable(html: string): TeamRank[] {
|
||||||
|
const teams: TeamRank[] = [];
|
||||||
|
|
||||||
|
// <tr> 안의 <td> 들을 추출
|
||||||
|
const trRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
|
||||||
|
let trMatch: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((trMatch = trRegex.exec(html)) !== null) {
|
||||||
|
const trContent = trMatch[1];
|
||||||
|
const tds: string[] = [];
|
||||||
|
const tdRegex = /<td[^>]*>([\s\S]*?)<\/td>/gi;
|
||||||
|
let tdMatch: RegExpExecArray | null;
|
||||||
|
while ((tdMatch = tdRegex.exec(trContent)) !== null) {
|
||||||
|
tds.push(stripTags(decodeHtmlEntities(tdMatch[1])));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 팀 순위 행은 12개 컬럼: 순위, 팀명, 경기, 승, 패, 무, 승률, 게임차, 최근10경기, 연속, 홈, 방문
|
||||||
|
if (tds.length >= 10) {
|
||||||
|
// 팀명이 한글/영문인지 간단 체크
|
||||||
|
const teamName = tds[1];
|
||||||
|
if (/^[A-Z가-힣]/.test(teamName)) {
|
||||||
|
teams.push({
|
||||||
|
순위: tds[0],
|
||||||
|
팀명: tds[1],
|
||||||
|
경기: tds[2],
|
||||||
|
승: tds[3],
|
||||||
|
패: tds[4],
|
||||||
|
무: tds[5],
|
||||||
|
승률: tds[6],
|
||||||
|
게임차: tds[7],
|
||||||
|
최근10경기: tds[8],
|
||||||
|
연속: tds[9],
|
||||||
|
홈: tds[10] ?? "",
|
||||||
|
방문: tds[11] ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return teams;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWLD(s: string): WinLossDraw {
|
||||||
|
const parts = s.split("-").map(Number);
|
||||||
|
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVsTable(html: string): TeamVsRecord[] {
|
||||||
|
const tableMatch = html.match(
|
||||||
|
/<table[^>]*summary="팀간승패표"[^>]*>([\s\S]*?)<\/table>/
|
||||||
|
);
|
||||||
|
if (!tableMatch) return [];
|
||||||
|
|
||||||
|
const tableHtml = tableMatch[1];
|
||||||
|
|
||||||
|
// 헤더에서 팀명 목록 추출
|
||||||
|
const theadMatch = tableHtml.match(/<thead[^>]*>([\s\S]*?)<\/thead>/);
|
||||||
|
const teamNames: string[] = [];
|
||||||
|
if (theadMatch) {
|
||||||
|
const thRegex = /<th[^>]*>([\s\S]*?)<\/th>/gi;
|
||||||
|
let thMatch: RegExpExecArray | null;
|
||||||
|
while ((thMatch = thRegex.exec(theadMatch[1])) !== null) {
|
||||||
|
const text = stripTags(decodeHtmlEntities(thMatch[1])).replace(/\(승-패-무\)/g, "").trim();
|
||||||
|
if (text && text !== "팀명" && text !== "합계") {
|
||||||
|
teamNames.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 바디에서 각 팀의 상대전적 추출
|
||||||
|
const records: TeamVsRecord[] = [];
|
||||||
|
const tbodyMatch = tableHtml.match(/<tbody[^>]*>([\s\S]*?)<\/tbody>/);
|
||||||
|
if (!tbodyMatch) return [];
|
||||||
|
|
||||||
|
const trRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
|
||||||
|
let trMatch: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((trMatch = trRegex.exec(tbodyMatch[1])) !== null) {
|
||||||
|
const cells: string[] = [];
|
||||||
|
const tdRegex = /<td[^>]*>([\s\S]*?)<\/td>/gi;
|
||||||
|
let tdMatch: RegExpExecArray | null;
|
||||||
|
while ((tdMatch = tdRegex.exec(trMatch[1])) !== null) {
|
||||||
|
cells.push(stripTags(decodeHtmlEntities(tdMatch[1])));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cells.length < teamNames.length + 2) continue;
|
||||||
|
|
||||||
|
const 팀명 = cells[0];
|
||||||
|
const 상대전적: Record<string, WinLossDraw> = {};
|
||||||
|
|
||||||
|
for (let i = 0; i < teamNames.length; i++) {
|
||||||
|
const val = cells[i + 1];
|
||||||
|
if (val === "■" || val === "▲" || 팀명 === teamNames[i]) continue;
|
||||||
|
상대전적[teamNames[i]] = parseWLD(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
const 합계 = parseWLD(cells[cells.length - 1]);
|
||||||
|
records.push({ 팀명, 상대전적, 합계 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 네트워크 ──
|
||||||
|
|
||||||
|
async function getInitialPage(): Promise<{
|
||||||
|
html: string;
|
||||||
|
viewState: string;
|
||||||
|
viewStateGenerator: string;
|
||||||
|
eventValidation: string;
|
||||||
|
currentYear: number;
|
||||||
|
}> {
|
||||||
|
const res = await fetch(BASE_URL, {
|
||||||
|
headers: {
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/147.0.0.0 Safari/537.36",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const html = await res.text();
|
||||||
|
|
||||||
|
// 현재 선택된 연도 추출
|
||||||
|
const yearMatch = html.match(
|
||||||
|
/id="cphContents_cphContents_cphContents_lblSearchDateTitle">(\d{4})/
|
||||||
|
);
|
||||||
|
const currentYear = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
|
||||||
|
|
||||||
|
return {
|
||||||
|
html,
|
||||||
|
viewState: extractHiddenField(html, "__VIEWSTATE"),
|
||||||
|
viewStateGenerator: extractHiddenField(html, "__VIEWSTATEGENERATOR"),
|
||||||
|
eventValidation: extractHiddenField(html, "__EVENTVALIDATION"),
|
||||||
|
currentYear,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageState {
|
||||||
|
viewState: string;
|
||||||
|
viewStateGenerator: string;
|
||||||
|
eventValidation: string;
|
||||||
|
currentYear: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchYear(
|
||||||
|
year: number,
|
||||||
|
state: PageState
|
||||||
|
): Promise<{ teams: TeamRank[]; vsRecords: TeamVsRecord[]; newState: PageState }> {
|
||||||
|
const formData = new URLSearchParams();
|
||||||
|
formData.set(`${PREFIX}ScriptManager`, `${PREFIX}udpRecord|${PREFIX}ddlYear`);
|
||||||
|
formData.set(`${PREFIX}ddlYear`, String(year));
|
||||||
|
formData.set(`${PREFIX}ddlSeries`, "0");
|
||||||
|
// hfSearchYear/Date는 "현재 페이지가 보여주던 연도" (변경 전)
|
||||||
|
formData.set(`${PREFIX}hfSearchYear`, String(state.currentYear));
|
||||||
|
formData.set(`${PREFIX}hfSearchDate`, `${state.currentYear}1231`);
|
||||||
|
formData.set(`${PREFIX}hfSearchSeries`, "0");
|
||||||
|
formData.set("__EVENTTARGET", `${PREFIX}ddlYear`);
|
||||||
|
formData.set("__EVENTARGUMENT", "");
|
||||||
|
formData.set("__LASTFOCUS", "");
|
||||||
|
formData.set("__VIEWSTATE", state.viewState);
|
||||||
|
formData.set("__VIEWSTATEGENERATOR", state.viewStateGenerator);
|
||||||
|
formData.set("__EVENTVALIDATION", state.eventValidation);
|
||||||
|
formData.set("__ASYNCPOST", "true");
|
||||||
|
|
||||||
|
const res = await fetch(BASE_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
|
"X-MicrosoftAjax": "Delta=true",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/147.0.0.0 Safari/537.36",
|
||||||
|
Referer: BASE_URL,
|
||||||
|
Origin: "https://www.koreabaseball.com",
|
||||||
|
},
|
||||||
|
body: formData.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
|
||||||
|
// ASP.NET AJAX 델타 응답에서 HTML 부분 추출 (첫 번째 updatePanel 섹션)
|
||||||
|
const panelMatch = text.match(
|
||||||
|
/updatePanel\|[^|]*\|([\s\S]*?)\|[0-9]+\|hiddenField\|/
|
||||||
|
);
|
||||||
|
const htmlContent = panelMatch?.[1] ?? text;
|
||||||
|
|
||||||
|
// 업데이트된 hidden field 추출
|
||||||
|
const vsMatch = text.match(/__VIEWSTATE\|([^|]*)\|/);
|
||||||
|
const vsgMatch = text.match(/__VIEWSTATEGENERATOR\|([^|]*)\|/);
|
||||||
|
const evMatch = text.match(/__EVENTVALIDATION\|([^|]*)\|/);
|
||||||
|
|
||||||
|
const newState: PageState = {
|
||||||
|
viewState: vsMatch?.[1] ?? state.viewState,
|
||||||
|
viewStateGenerator: vsgMatch?.[1] ?? state.viewStateGenerator,
|
||||||
|
eventValidation: evMatch?.[1] ?? state.eventValidation,
|
||||||
|
currentYear: year,
|
||||||
|
};
|
||||||
|
|
||||||
|
const teams = parseRankTable(htmlContent);
|
||||||
|
const vsRecords = parseVsTable(htmlContent);
|
||||||
|
|
||||||
|
return { teams, vsRecords, newState };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 출력 ──
|
||||||
|
|
||||||
|
function printTable(year: number, teams: TeamRank[]) {
|
||||||
|
console.log(`\n${"═".repeat(90)}`);
|
||||||
|
console.log(` KBO ${year} 정규시즌 팀 순위`);
|
||||||
|
console.log(`${"═".repeat(90)}`);
|
||||||
|
|
||||||
|
if (teams.length === 0) {
|
||||||
|
console.log(" 데이터를 찾을 수 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = [
|
||||||
|
"순위",
|
||||||
|
"팀명",
|
||||||
|
"경기",
|
||||||
|
"승",
|
||||||
|
"패",
|
||||||
|
"무",
|
||||||
|
"승률",
|
||||||
|
"게임차",
|
||||||
|
"최근10경기",
|
||||||
|
"연속",
|
||||||
|
];
|
||||||
|
const widths = [4, 6, 4, 3, 3, 3, 6, 6, 12, 8];
|
||||||
|
|
||||||
|
const padCell = (val: string, w: number) => {
|
||||||
|
// 한글 글자는 폭 2로 계산
|
||||||
|
const displayWidth = [...val].reduce(
|
||||||
|
(sum, ch) => sum + (ch.charCodeAt(0) > 0x7f ? 2 : 1),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return val + " ".repeat(Math.max(0, w - displayWidth));
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
" " + header.map((h, i) => padCell(h, widths[i])).join(" │ ")
|
||||||
|
);
|
||||||
|
console.log(" " + widths.map((w) => "─".repeat(w)).join("─┼─"));
|
||||||
|
|
||||||
|
for (const t of teams) {
|
||||||
|
const row = [
|
||||||
|
t.순위,
|
||||||
|
t.팀명,
|
||||||
|
t.경기,
|
||||||
|
t.승,
|
||||||
|
t.패,
|
||||||
|
t.무,
|
||||||
|
t.승률,
|
||||||
|
t.게임차,
|
||||||
|
t.최근10경기,
|
||||||
|
t.연속,
|
||||||
|
];
|
||||||
|
console.log(
|
||||||
|
" " + row.map((val, i) => padCell(val, widths[i])).join(" │ ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printVsTable(year: number, vsRecords: TeamVsRecord[]) {
|
||||||
|
if (vsRecords.length === 0) return;
|
||||||
|
|
||||||
|
console.log(`\n${"─".repeat(90)}`);
|
||||||
|
console.log(` KBO ${year} 팀간 승패표 (승-패-무)`);
|
||||||
|
console.log(`${"─".repeat(90)}`);
|
||||||
|
|
||||||
|
const teamNames = vsRecords.map((r) => r.팀명);
|
||||||
|
const colW = 7;
|
||||||
|
|
||||||
|
const padCell = (val: string, w: number) => {
|
||||||
|
const displayWidth = [...val].reduce(
|
||||||
|
(sum, ch) => sum + (ch.charCodeAt(0) > 0x7f ? 2 : 1),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return val + " ".repeat(Math.max(0, w - displayWidth));
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtWLD = (wld: WinLossDraw) => `${wld[0]}-${wld[1]}-${wld[2]}`;
|
||||||
|
|
||||||
|
// 헤더
|
||||||
|
console.log(
|
||||||
|
" " +
|
||||||
|
padCell("", 6) +
|
||||||
|
" │ " +
|
||||||
|
teamNames.map((n) => padCell(n, colW)).join("│ ") +
|
||||||
|
"│ " +
|
||||||
|
padCell("합계", colW)
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
" " +
|
||||||
|
"─".repeat(6) +
|
||||||
|
"─┼─" +
|
||||||
|
teamNames.map(() => "─".repeat(colW)).join("┼─") +
|
||||||
|
"┼─" +
|
||||||
|
"─".repeat(colW)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const rec of vsRecords) {
|
||||||
|
const cells = teamNames.map((name) => {
|
||||||
|
if (name === rec.팀명) return padCell(" ■", colW);
|
||||||
|
const wld = rec.상대전적[name];
|
||||||
|
return padCell(wld ? fmtWLD(wld) : "-", colW);
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
" " +
|
||||||
|
padCell(rec.팀명, 6) +
|
||||||
|
" │ " +
|
||||||
|
cells.join("│ ") +
|
||||||
|
"│ " +
|
||||||
|
padCell(fmtWLD(rec.합계), colW)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON 출력 ──
|
||||||
|
|
||||||
|
interface YearResult {
|
||||||
|
year: number;
|
||||||
|
teams: TeamRank[];
|
||||||
|
vsRecords: TeamVsRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function printJson(results: YearResult[]) {
|
||||||
|
console.log(JSON.stringify(results, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ──
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
let jsonMode = false;
|
||||||
|
const years: number[] = [];
|
||||||
|
|
||||||
|
for (const arg of args) {
|
||||||
|
if (arg === "--json") {
|
||||||
|
jsonMode = true;
|
||||||
|
} else {
|
||||||
|
const y = parseInt(arg, 10);
|
||||||
|
if (y >= 1982 && y <= 2026) {
|
||||||
|
years.push(y);
|
||||||
|
} else {
|
||||||
|
console.error(`유효하지 않은 연도: ${arg} (1982~2026)`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (years.length === 0) {
|
||||||
|
years.push(new Date().getFullYear());
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("KBO 팀 순위 데이터를 가져오는 중...\n");
|
||||||
|
|
||||||
|
// 1단계: 초기 페이지 가져오기
|
||||||
|
const initial = await getInitialPage();
|
||||||
|
let state: PageState = {
|
||||||
|
viewState: initial.viewState,
|
||||||
|
viewStateGenerator: initial.viewStateGenerator,
|
||||||
|
eventValidation: initial.eventValidation,
|
||||||
|
currentYear: initial.currentYear,
|
||||||
|
};
|
||||||
|
|
||||||
|
const results: YearResult[] = [];
|
||||||
|
|
||||||
|
// 2단계: 연도별 데이터 가져오기 (순차 — 상태 의존)
|
||||||
|
for (const year of years) {
|
||||||
|
process.stdout.write(` ${year}년 데이터 요청 중...`);
|
||||||
|
|
||||||
|
if (year === initial.currentYear && results.length === 0) {
|
||||||
|
const teams = parseRankTable(initial.html);
|
||||||
|
const vsRecords = parseVsTable(initial.html);
|
||||||
|
results.push({ year, teams, vsRecords });
|
||||||
|
console.log(` ${teams.length}개 팀 로드 완료 (초기 페이지)`);
|
||||||
|
} else {
|
||||||
|
const { teams, vsRecords, newState } = await fetchYear(year, state);
|
||||||
|
state = newState;
|
||||||
|
results.push({ year, teams, vsRecords });
|
||||||
|
console.log(` ${teams.length}개 팀 로드 완료`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3단계: 출력
|
||||||
|
if (jsonMode) {
|
||||||
|
printJson(results);
|
||||||
|
} else {
|
||||||
|
for (const { year, teams, vsRecords } of results) {
|
||||||
|
printTable(year, teams);
|
||||||
|
printVsTable(year, vsRecords);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("오류 발생:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user