This commit is contained in:
윤정민 2026-04-13 09:18:30 +09:00
parent fe0ae5b372
commit b58479bfe7
17 changed files with 1811 additions and 156 deletions

View File

@ -19,6 +19,9 @@ module.exports = {
},
ignorePatterns: [
"/lib/**/*", // Ignore built files.
"/dist/**/*", // Ignore compiled output.
"/tests/**/*",
"vitest.config.ts",
"/generated/**/*", // Ignore generated files.
],
plugins: [
@ -26,8 +29,13 @@ module.exports = {
"import",
],
rules: {
"quotes": ["error", "double"],
quotes: ["error", "double"],
"import/no-unresolved": 0,
"indent": ["error", 2],
indent: ["error", 2],
"require-jsdoc": "off",
"valid-jsdoc": "off",
"max-len": ["error", { code: 120 }],
"object-curly-spacing": ["error", "always"],
"quote-props": ["error", "as-needed"],
},
};

View File

@ -1,7 +1,5 @@
{
"projects": {
"default": "mmday-7900f"
},
"targets": {},
"etags": {}
"default": "mmday-panit"
}
}

View File

@ -1,6 +1,23 @@
{
"rules": {
".read": "auth != null",
".write": "auth != null"
"votes": {
"$gameId": {
".read": "auth != null",
"counts": {
".read": true
},
".write": false
}
},
"userVotes": {
"$uid": {
".read": "auth != null && auth.uid == $uid",
".write": false
}
},
"cache": {
".read": false,
".write": false
}
}
}

View File

@ -1,6 +1,6 @@
{
"database": {
"rules": "y"
"rules": "database.rules.json"
},
"firestore": {
"database": "(default)",
@ -21,7 +21,6 @@
"*.local"
],
"predeploy": [
"npm --prefix \"$RESOURCE_DIR\" run lint",
"npm --prefix \"$RESOURCE_DIR\" run build"
]
}
@ -44,5 +43,35 @@
},
"auth": {
"providers": {}
},
"emulators": {
"auth": {
"host": "0.0.0.0",
"port": 9099
},
"functions": {
"host": "0.0.0.0",
"port": 5001
},
"firestore": {
"host": "0.0.0.0",
"port": 8080
},
"database": {
"host": "0.0.0.0",
"port": 9000
},
"storage": {
"host": "0.0.0.0",
"port": 9199
},
"ui": {
"enabled": true,
"host": "0.0.0.0"
},
"singleProjectMode": true
},
"remoteconfig": {
"template": "remoteconfig.template.json"
}
}

View File

@ -1,4 +1,51 @@
{
// Example (Standard Edition):
//
// "indexes": [
// {
// "collectionGroup": "widgets",
// "queryScope": "COLLECTION",
// "fields": [
// { "fieldPath": "foo", "arrayConfig": "CONTAINS" },
// { "fieldPath": "bar", "mode": "DESCENDING" }
// ]
// },
//
// "fieldOverrides": [
// {
// "collectionGroup": "widgets",
// "fieldPath": "baz",
// "indexes": [
// { "order": "ASCENDING", "queryScope": "COLLECTION" }
// ]
// },
// ]
// ]
//
// Example (Enterprise Edition):
//
// "indexes": [
// {
// "collectionGroup": "reviews",
// "queryScope": "COLLECTION_GROUP",
// "apiScope": "MONGODB_COMPATIBLE_API",
// "density": "DENSE",
// "multikey": false,
// "fields": [
// { "fieldPath": "baz", "mode": "ASCENDING" }
// ]
// },
// {
// "collectionGroup": "items",
// "queryScope": "COLLECTION_GROUP",
// "apiScope": "MONGODB_COMPATIBLE_API",
// "density": "SPARSE_ANY",
// "multikey": true,
// "fields": [
// { "fieldPath": "baz", "mode": "ASCENDING" }
// ]
// },
// ]
"indexes": [],
"fieldOverrides": []
}

View File

@ -1,19 +1,33 @@
rules_version = '2';
rules_version='2'
service cloud.firestore {
match /databases/{database}/documents {
match /users/{uid} {
allow read: if request.auth != null && request.auth.uid == uid;
allow write: if false;
match /voteHistory/{date} {
allow read: if request.auth != null && request.auth.uid == uid;
allow write: if false;
}
}
match /games/{gameId} {
allow read: if true;
allow write: if false;
}
match /kboCache/{key} {
allow read: if true;
allow write: if false;
}
match /kboLocks/{key} {
allow read, write: if false;
}
// This rule allows anyone with your Firestore database reference to view, edit,
// and delete all data in your Firestore database. It is useful for getting
// started, but it is configured to expire after 30 days because it
// leaves your app open to attackers. At that time, all client
// requests to your Firestore database will be denied.
//
// Make sure to write security rules for your app before that time, or else
// all client requests to your Firestore database will be denied until you Update
// your rules
match /{document=**} {
allow read, write: if request.time < timestamp.date(2026, 5, 2);
allow read, write: if false;
}
}
}

1553
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,11 +4,13 @@
"lint": "eslint --ext .js,.ts .",
"build": "tsc",
"build:watch": "tsc --watch",
"serve": "npm run build && firebase emulators:start --only functions",
"serve": "npm run build && firebase emulators:start --only auth,functions,firestore,database,storage",
"shell": "npm run build && firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
"logs": "firebase functions:log",
"test": "firebase emulators:exec --only firestore,database,auth \"vitest run\"",
"test:watch": "vitest"
},
"engines": {
"node": "24"
@ -25,7 +27,8 @@
"eslint-config-google": "^0.14.0",
"eslint-plugin-import": "^2.25.4",
"firebase-functions-test": "^3.4.1",
"typescript": "^5.7.3"
"typescript": "^5.7.3",
"vitest": "^4.1.4"
},
"private": true
}

View File

@ -1,32 +1,12 @@
/**
* Import function triggers from their respective submodules:
*
* import {onCall} from "firebase-functions/v2/https";
* import {onDocumentWritten} from "firebase-functions/v2/firestore";
*
* See a full list of supported triggers at https://firebase.google.com/docs/functions
*/
import { setGlobalOptions } from "firebase-functions";
import "./firebase";
import {setGlobalOptions} from "firebase-functions";
import {onRequest} from "firebase-functions/https";
import * as logger from "firebase-functions/logger";
setGlobalOptions({ maxInstances: 10, region: "asia-northeast3" });
// Start writing functions
// https://firebase.google.com/docs/functions/typescript
// For cost control, you can set the maximum number of containers that can be
// running at the same time. This helps mitigate the impact of unexpected
// traffic spikes by instead downgrading performance. This limit is a
// per-function limit. You can override the limit for each function using the
// `maxInstances` option in the function's options, e.g.
// `onRequest({ maxInstances: 5 }, (req, res) => { ... })`.
// NOTE: setGlobalOptions does not apply to functions using the v1 API. V1
// functions should each use functions.runWith({ maxInstances: 10 }) instead.
// In the v1 API, each function can only serve one request per container, so
// this will be the maximum concurrent request count.
setGlobalOptions({ maxInstances: 10 });
// export const helloWorld = onRequest((request, response) => {
// logger.info("Hello logs!", {structuredData: true});
// response.send("Hello from Firebase!");
// });
export { kbo } from "./handlers/kboHandlers";
export { auth } from "./handlers/authHandlers";
export { prediction } from "./handlers/predictionHandlers";
export { stats } from "./handlers/statsHandlers";
export { admin } from "./handlers/adminHandlers";
export { kboDailyRefresh } from "./scheduled/kboRefresh";
export { dailyArchive } from "./scheduled/dailyArchive";

View File

@ -5,11 +5,13 @@
* Commands:
* rank [year...]
* player <hitter|pitcher|defense|runner>
* schedule [year] [month] /
*
* Options:
* --json JSON
* --all (player)
* --team= (e.g. --team=LG)
* --series= (schedule: 정규//)
*/
import { padCell } from "./html-utils.js";
@ -34,6 +36,12 @@ import { HITTER_CONFIG } from "./player/hitter.js";
import { PITCHER_CONFIG } from "./player/pitcher.js";
import { DEFENSE_CONFIG } from "./player/defense.js";
import { RUNNER_CONFIG } from "./player/runner.js";
import {
fetchSchedule,
SERIES_CODES,
type ScheduleGame,
type ScheduleResult,
} from "./schedule.js";
const PLAYER_CONFIGS: Record<string, PlayerPageConfig> = {
hitter: HITTER_CONFIG,
@ -118,9 +126,9 @@ function printPlayerTable(
const filterDesc = Object.entries(result.filters)
.map(([k, v]) => `${k}=${v}`)
.join(", ");
const label = filterDesc
? `KBO ${result.year} ${subcommand} (${filterDesc})`
: `KBO ${result.year} ${subcommand}`;
const label = filterDesc ?
`KBO ${result.year} ${subcommand} (${filterDesc})` :
`KBO ${result.year} ${subcommand}`;
console.log(`\n${"═".repeat(100)}`);
console.log(` ${label}`);
@ -143,7 +151,7 @@ function printPlayerTable(
console.log(" " + widths.map((w) => "-".repeat(w)).join("-+-"));
for (const rec of result.records) {
const row = columns.map((c) => rec[c] ?? "");
const row = columns.map((c) => String(rec[c] ?? ""));
console.log(
" " + row.map((val, i) => padCell(val, widths[i])).join(" | ")
);
@ -152,8 +160,89 @@ function printPlayerTable(
console.log(`\n Total: ${result.records.length} players`);
}
// ── Schedule Formatting ──
function fmtScore(game: ScheduleGame): string {
if (game.status === "completed") {
const away = String(game.awayScore ?? 0).padStart(2);
const home = String(game.homeScore ?? 0).padStart(2);
return `${away} vs ${home}`;
}
if (game.status === "cancelled") {
return " 취소 ";
}
return " vs ";
}
function printScheduleTable(result: ScheduleResult) {
const label = `KBO ${result.year}-${String(result.month).padStart(2, "0")} Schedule`;
console.log(`\n${"═".repeat(75)}`);
console.log(` ${label}`);
console.log(`${"═".repeat(75)}`);
if (result.games.length === 0) {
console.log(" No data found.");
return;
}
let lastDate = "";
for (const game of result.games) {
const dateKey = `${game.date}(${game.dayOfWeek})`;
if (dateKey !== lastDate) {
if (lastDate) console.log(` ${"─".repeat(71)}`);
console.log(` ${dateKey}`);
lastDate = dateKey;
}
const time = padCell(game.time, 5);
const away = padCell(game.awayTeamCode, 4);
const home = padCell(game.homeTeamCode, 4);
const score = fmtScore(game);
const stadium = padCell(game.stadium, 4);
const broadcast = game.broadcast || "";
let line = ` ${time} ${away} ${score} ${home} ${stadium}`;
if (broadcast) line += ` ${broadcast}`;
if (game.note && game.note !== "-") line += ` ${game.note}`;
console.log(line);
}
}
// ── Commands ──
async function scheduleCommand(
year: number,
month: number,
jsonMode: boolean,
team?: string,
series?: string
) {
const monthStr = String(month).padStart(2, "0");
console.log(`Fetching KBO schedule for ${year}-${monthStr}...\n`);
const resolvedTeam = team ? (TEAM_CODES[team] ?? team) : undefined;
const resolvedSeries = series ? (SERIES_CODES[series] ?? series) : undefined;
const result = await fetchSchedule({
year,
month,
series: resolvedSeries,
team: resolvedTeam,
});
console.log(` ${result.games.length} games found`);
if (jsonMode) {
console.log(JSON.stringify(result, null, 2));
} else {
printScheduleTable(result);
}
}
async function rankCommand(years: number[], jsonMode: boolean) {
console.log("Fetching KBO team rankings...\n");
@ -236,6 +325,7 @@ function printHelp() {
console.log("Commands:");
console.log(" rank [year...] Team rankings");
console.log(" player <hitter|pitcher|defense|runner> Player stats");
console.log(" schedule [year] [month] 경기 일정/결과");
console.log("");
console.log("Options:");
console.log(" --json JSON output");
@ -247,6 +337,10 @@ function printHelp() {
console.log(" --pos=값 포지션 (2=포수, 3,4,5,6=내야수, 7,8,9=외야수)");
console.log(" --situation=값 상황별 (MONTH_SC, WEEK_SC, STADIUM_SC, HOMEAYAY_SC, ...)");
console.log(" --situationDetail=값");
console.log("");
console.log("Schedule filters:");
console.log(" --team=팀명 팀 (LG, 삼성, KT, ...)");
console.log(" --series=값 시리즈 (정규, 시범, 포스트)");
}
async function main() {
@ -317,6 +411,39 @@ async function main() {
return;
}
if (command === "schedule") {
const rest = args.slice(1);
let jsonMode = false;
const now = new Date();
let year = now.getFullYear();
let month = now.getMonth() + 1;
let schedTeam: string | undefined;
let schedSeries: string | undefined;
for (const arg of rest) {
if (arg === "--json") {
jsonMode = true;
} else if (arg.startsWith("--team=")) {
schedTeam = arg.slice("--team=".length);
} else if (arg.startsWith("--series=")) {
schedSeries = arg.slice("--series=".length);
} else {
const n = parseInt(arg, 10);
if (n >= 1 && n <= 12) {
month = n;
} else if (n >= 1982 && n <= 2100) {
year = n;
} else {
console.error(`Invalid argument: ${arg}`);
process.exit(1);
}
}
}
await scheduleCommand(year, month, jsonMode, schedTeam, schedSeries);
return;
}
// rank 및 기타 커맨드
const rest = args.slice(1);
let jsonMode = false;
@ -337,12 +464,12 @@ async function main() {
if (years.length === 0) years.push(new Date().getFullYear());
switch (command) {
case "rank":
await rankCommand(years, jsonMode);
break;
default:
console.error(`Unknown command: ${command}`);
process.exit(1);
case "rank":
await rankCommand(years, jsonMode);
break;
default:
console.error(`Unknown command: ${command}`);
process.exit(1);
}
}

View File

@ -16,7 +16,7 @@ export function decodeHtmlEntities(text: string): string {
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&quot;/g, "\"")
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
}

View File

@ -43,7 +43,19 @@ export interface PlayerPageConfig {
* const hitter = record as HitterStats;
* console.log(hitter.avg, hitter.hr);
*/
export type PlayerRecord = Record<string, string>;
export type PlayerValue = string | number | null;
export type PlayerRecord = Record<string, PlayerValue>;
/** 숫자로 변환하지 않고 문자열 그대로 유지할 컬럼 */
const STRING_COLUMNS: ReadonlySet<string> = new Set(["player", "team", "pos"]);
function parsePlayerCell(col: string, text: string): PlayerValue {
if (STRING_COLUMNS.has(col)) return text;
const trimmed = text.trim();
if (trimmed === "" || trimmed === "-") return null;
const n = Number(trimmed);
return Number.isNaN(n) ? trimmed : n;
}
/**
* .
@ -95,7 +107,7 @@ export const TEAM_CODES: Record<string, string> = {
*
* @param html - `<tr>`, `<td>` HTML
* @param columns - `<td>` ( )
* @returns `{ [컬럼명]: 값 }` .
* @return `{ [컬럼명]: 값 }` .
* `<td>` () .
*/
export function parsePlayerTable(
@ -121,7 +133,7 @@ export function parsePlayerTable(
if (/^\d+$/.test(rank)) {
const record: PlayerRecord = {};
for (let i = 0; i < columns.length; i++) {
record[columns[i]] = tds[i];
record[columns[i]] = parsePlayerCell(columns[i], tds[i]);
}
records.push(record);
}
@ -157,7 +169,7 @@ const DDL_DEFAULTS: Record<string, string> = {
* @param config - (dropdowns, defaultSortCol )
* @param year -
* @param filters - (team, series, pos )
* @returns `URLSearchParams` -
* @return `URLSearchParams` -
*/
function buildFormFields(
config: PlayerPageConfig,
@ -200,7 +212,7 @@ function buildFormFields(
* postback에 ASP.NET (ViewState, ) .
*
* @param config - (url, columns )
* @returns (result) (state)
* @return (result) (state)
*/
export async function fetchPlayerStatsInitial(
config: PlayerPageConfig
@ -210,9 +222,9 @@ export async function fetchPlayerStatsInitial(
const yearMatch = html.match(
/ddlSeason_ddlSeason[\s\S]*?selected="selected"\s+value="(\d{4})"/
);
const year = yearMatch
? parseInt(yearMatch[1], 10)
: new Date().getFullYear();
const year = yearMatch ?
parseInt(yearMatch[1], 10) :
new Date().getFullYear();
return {
result: {
@ -239,7 +251,7 @@ export async function fetchPlayerStatsInitial(
* @param year -
* @param state - ASP.NET
* @param filters - (team, series, pos, situation )
* @returns (result) (newState)
* @return (result) (newState)
*/
export async function fetchPlayerStats(
config: PlayerPageConfig,
@ -252,9 +264,9 @@ export async function fetchPlayerStats(
const useUrl =
isPostseason && config.postseasonUrl ? config.postseasonUrl : config.url;
const useColumns =
isPostseason && config.postseasonColumns
? config.postseasonColumns
: config.columns;
isPostseason && config.postseasonColumns ?
config.postseasonColumns :
config.columns;
// 포스트시즌은 별도 페이지이므로 해당 페이지의 초기 상태에서 시작
if (isPostseason && config.postseasonUrl) {
@ -373,7 +385,7 @@ export async function fetchPlayerStats(
* @param year -
* @param state - ASP.NET
* @param filters -
* @returns
* @return
*/
export async function fetchAllPlayerStats(
config: PlayerPageConfig,
@ -388,6 +400,7 @@ export async function fetchAllPlayerStats(
let currentState = firstState;
let page = 2;
// eslint-disable-next-line no-constant-condition
while (true) {
const pagerTarget = `${PREFIX}ucPager$btnNo${page}`;
const fields = buildFormFields(config, year, filters);

View File

@ -1,4 +1,4 @@
import type { PlayerPageConfig } from "./common.js";
import type { PlayerPageConfig, PlayerValue } from "./common.js";
export const DEFENSE_COLUMNS = [
"rank", "player", "team", "pos", "games", "gs", "ip",
@ -6,7 +6,7 @@ export const DEFENSE_COLUMNS = [
"sb", "cs", "csPct",
] as const;
export type DefenseStats = Record<(typeof DEFENSE_COLUMNS)[number], string>;
export type DefenseStats = Record<(typeof DEFENSE_COLUMNS)[number], PlayerValue>;
export const DEFENSE_CONFIG: PlayerPageConfig = {
url: "https://www.koreabaseball.com/Record/Player/Defense/Basic.aspx",

View File

@ -1,4 +1,4 @@
import type { PlayerPageConfig } from "./common.js";
import type { PlayerPageConfig, PlayerValue } from "./common.js";
export const HITTER_COLUMNS = [
"rank", "player", "team", "avg", "games", "pa", "ab",
@ -11,7 +11,7 @@ export const HITTER_POSTSEASON_COLUMNS = [
"bb", "hbp", "so", "gdp", "errors",
] as const;
export type HitterStats = Record<(typeof HITTER_COLUMNS)[number], string>;
export type HitterStats = Record<(typeof HITTER_COLUMNS)[number], PlayerValue>;
export const HITTER_CONFIG: PlayerPageConfig = {
url: "https://www.koreabaseball.com/Record/Player/HitterBasic/Basic1.aspx",

View File

@ -1,4 +1,4 @@
import type { PlayerPageConfig } from "./common.js";
import type { PlayerPageConfig, PlayerValue } from "./common.js";
export const PITCHER_COLUMNS = [
"rank", "player", "team", "era", "games", "wins", "losses",
@ -12,7 +12,7 @@ export const PITCHER_POSTSEASON_COLUMNS = [
"hits", "hr", "bb", "hbp", "so", "runs", "er",
] as const;
export type PitcherStats = Record<(typeof PITCHER_COLUMNS)[number], string>;
export type PitcherStats = Record<(typeof PITCHER_COLUMNS)[number], PlayerValue>;
export const PITCHER_CONFIG: PlayerPageConfig = {
url: "https://www.koreabaseball.com/Record/Player/PitcherBasic/Basic1.aspx",

View File

@ -1,11 +1,11 @@
import type { PlayerPageConfig } from "./common.js";
import type { PlayerPageConfig, PlayerValue } from "./common.js";
export const RUNNER_COLUMNS = [
"rank", "player", "team", "games", "sba", "sb", "cs",
"sbPct", "oob", "pko",
] as const;
export type RunnerStats = Record<(typeof RUNNER_COLUMNS)[number], string>;
export type RunnerStats = Record<(typeof RUNNER_COLUMNS)[number], PlayerValue>;
export const RUNNER_CONFIG: PlayerPageConfig = {
url: "https://www.koreabaseball.com/Record/Player/Runner/Basic.aspx",

View File

@ -8,7 +8,7 @@
"outDir": "lib",
"sourceMap": true,
"strict": true,
"target": "es2017"
"target": "es2017",
},
"compileOnSave": true,
"include": [