init
This commit is contained in:
commit
a3a13e408a
33
.eslintrc.js
Normal file
33
.eslintrc.js
Normal file
@ -0,0 +1,33 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
es6: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:import/errors",
|
||||
"plugin:import/warnings",
|
||||
"plugin:import/typescript",
|
||||
"google",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: ["tsconfig.json", "tsconfig.dev.json"],
|
||||
sourceType: "module",
|
||||
},
|
||||
ignorePatterns: [
|
||||
"/lib/**/*", // Ignore built files.
|
||||
"/generated/**/*", // Ignore generated files.
|
||||
],
|
||||
plugins: [
|
||||
"@typescript-eslint",
|
||||
"import",
|
||||
],
|
||||
rules: {
|
||||
"quotes": ["error", "double"],
|
||||
"import/no-unresolved": 0,
|
||||
"indent": ["error", 2],
|
||||
},
|
||||
};
|
||||
7
.firebaserc
Normal file
7
.firebaserc
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"projects": {
|
||||
"default": "mmday-7900f"
|
||||
},
|
||||
"targets": {},
|
||||
"etags": {}
|
||||
}
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
# Compiled JavaScript files
|
||||
lib/**/*.js
|
||||
lib/**/*.js.map
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Node.js dependency directory
|
||||
node_modules/
|
||||
*.local
|
||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
# 디폴트 무시된 파일
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 에디터 기반 HTTP 클라이언트 요청
|
||||
/httpRequests/
|
||||
# 쿼리 파일을 포함한 무시된 디폴트 폴더
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
7
.idea/discord.xml
generated
Normal file
7
.idea/discord.xml
generated
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DiscordProjectSettings">
|
||||
<option name="show" value="ASK" />
|
||||
<option name="description" value="" />
|
||||
</component>
|
||||
</project>
|
||||
29
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
29
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@ -0,0 +1,29 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="IncorrectHttpHeaderInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="customHeaders">
|
||||
<set>
|
||||
<option value=""name"" />
|
||||
</set>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="ignoredPackages">
|
||||
<value>
|
||||
<list size="1">
|
||||
<item index="0" class="java.lang.String" itemvalue="uvloop" />
|
||||
</list>
|
||||
</value>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PyPep8Inspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="ignoredErrors">
|
||||
<list>
|
||||
<option value="N803" />
|
||||
</list>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
8
.idea/mmday-firebase.iml
generated
Normal file
8
.idea/mmday-firebase.iml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/mmday-firebase.iml" filepath="$PROJECT_DIR$/.idea/mmday-firebase.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
6
database.rules.json
Normal file
6
database.rules.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"rules": {
|
||||
".read": "auth != null",
|
||||
".write": "auth != null"
|
||||
}
|
||||
}
|
||||
1
dist/index.d.ts
vendored
Normal file
1
dist/index.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
export declare const helloWorld: import("firebase-functions/v2/https").HttpsFunction;
|
||||
10
dist/index.js
vendored
Normal file
10
dist/index.js
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.helloWorld = void 0;
|
||||
const app_1 = require("firebase-admin/app");
|
||||
const https_1 = require("firebase-functions/v2/https");
|
||||
(0, app_1.initializeApp)();
|
||||
exports.helloWorld = (0, https_1.onRequest)((req, res) => {
|
||||
res.json({ message: "Hello from mmday!" });
|
||||
});
|
||||
//# sourceMappingURL=index.js.map
|
||||
1
dist/index.js.map
vendored
Normal file
1
dist/index.js.map
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;AAAA,4CAAmD;AACnD,uDAAwD;AAExD,IAAA,mBAAa,GAAE,CAAC;AAEH,QAAA,UAAU,GAAG,IAAA,iBAAS,EAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC/C,GAAG,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,mBAAmB,EAAE,CAAC,CAAC;AAC7C,CAAC,CAAC,CAAC"}
|
||||
48
firebase.json
Normal file
48
firebase.json
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
"database": {
|
||||
"rules": "y"
|
||||
},
|
||||
"firestore": {
|
||||
"database": "(default)",
|
||||
"location": "asia-northeast3",
|
||||
"rules": "firestore.rules",
|
||||
"indexes": "firestore.indexes.json"
|
||||
},
|
||||
"functions": [
|
||||
{
|
||||
"codebase": "default",
|
||||
"source": ".",
|
||||
"runtime": "nodejs22",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git",
|
||||
"firebase-debug.log",
|
||||
"firebase-debug.*.log",
|
||||
"*.local"
|
||||
],
|
||||
"predeploy": [
|
||||
"npm --prefix \"$RESOURCE_DIR\" run lint",
|
||||
"npm --prefix \"$RESOURCE_DIR\" run build"
|
||||
]
|
||||
}
|
||||
],
|
||||
"hosting": {
|
||||
"ignore": [
|
||||
"firebase.json",
|
||||
"**/.*",
|
||||
"**/node_modules/**"
|
||||
],
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "**",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
"storage": {
|
||||
"rules": "storage.rules"
|
||||
},
|
||||
"auth": {
|
||||
"providers": {}
|
||||
}
|
||||
}
|
||||
4
firestore.indexes.json
Normal file
4
firestore.indexes.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"indexes": [],
|
||||
"fieldOverrides": []
|
||||
}
|
||||
19
firestore.rules
Normal file
19
firestore.rules
Normal file
@ -0,0 +1,19 @@
|
||||
rules_version = '2';
|
||||
|
||||
service cloud.firestore {
|
||||
match /databases/{database}/documents {
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
10146
package-lock.json
generated
Normal file
10146
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "functions",
|
||||
"scripts": {
|
||||
"lint": "eslint --ext .js,.ts .",
|
||||
"build": "tsc",
|
||||
"build:watch": "tsc --watch",
|
||||
"serve": "npm run build && firebase emulators:start --only functions",
|
||||
"shell": "npm run build && firebase functions:shell",
|
||||
"start": "npm run shell",
|
||||
"deploy": "firebase deploy --only functions",
|
||||
"logs": "firebase functions:log"
|
||||
},
|
||||
"engines": {
|
||||
"node": "24"
|
||||
},
|
||||
"main": "lib/index.js",
|
||||
"dependencies": {
|
||||
"firebase-admin": "^13.6.0",
|
||||
"firebase-functions": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^5.12.0",
|
||||
"@typescript-eslint/parser": "^5.12.0",
|
||||
"eslint": "^8.9.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-plugin-import": "^2.25.4",
|
||||
"firebase-functions-test": "^3.4.1",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
89
public/index.html
Normal file
89
public/index.html
Normal file
@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Welcome to Firebase Hosting</title>
|
||||
|
||||
<!-- update the version number as needed -->
|
||||
<script defer src="/__/firebase/12.11.0/firebase-app-compat.js"></script>
|
||||
<!-- include only the Firebase features as you need -->
|
||||
<script defer src="/__/firebase/12.11.0/firebase-auth-compat.js"></script>
|
||||
<script defer src="/__/firebase/12.11.0/firebase-database-compat.js"></script>
|
||||
<script defer src="/__/firebase/12.11.0/firebase-firestore-compat.js"></script>
|
||||
<script defer src="/__/firebase/12.11.0/firebase-functions-compat.js"></script>
|
||||
<script defer src="/__/firebase/12.11.0/firebase-messaging-compat.js"></script>
|
||||
<script defer src="/__/firebase/12.11.0/firebase-storage-compat.js"></script>
|
||||
<script defer src="/__/firebase/12.11.0/firebase-analytics-compat.js"></script>
|
||||
<script defer src="/__/firebase/12.11.0/firebase-remote-config-compat.js"></script>
|
||||
<script defer src="/__/firebase/12.11.0/firebase-performance-compat.js"></script>
|
||||
<!--
|
||||
initialize the SDK after all desired features are loaded, set useEmulator to false
|
||||
to avoid connecting the SDK to running emulators.
|
||||
-->
|
||||
<script defer src="/__/firebase/init.js?useEmulator=true"></script>
|
||||
|
||||
<style media="screen">
|
||||
body { background: #ECEFF1; color: rgba(0,0,0,0.87); font-family: Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; }
|
||||
#message { background: white; max-width: 360px; margin: 100px auto 16px; padding: 32px 24px; border-radius: 3px; }
|
||||
#message h2 { color: #ffa100; font-weight: bold; font-size: 16px; margin: 0 0 8px; }
|
||||
#message h1 { font-size: 22px; font-weight: 300; color: rgba(0,0,0,0.6); margin: 0 0 16px;}
|
||||
#message p { line-height: 140%; margin: 16px 0 24px; font-size: 14px; }
|
||||
#message a { display: block; text-align: center; background: #039be5; text-transform: uppercase; text-decoration: none; color: white; padding: 16px; border-radius: 4px; }
|
||||
#message, #message a { box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); }
|
||||
#load { color: rgba(0,0,0,0.4); text-align: center; font-size: 13px; }
|
||||
@media (max-width: 600px) {
|
||||
body, #message { margin-top: 0; background: white; box-shadow: none; }
|
||||
body { border-top: 16px solid #ffa100; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message">
|
||||
<h2>Welcome</h2>
|
||||
<h1>Firebase Hosting Setup Complete</h1>
|
||||
<p>You're seeing this because you've successfully setup Firebase Hosting. Now it's time to go build something extraordinary!</p>
|
||||
<a target="_blank" href="https://firebase.google.com/docs/hosting/">Open Hosting Documentation</a>
|
||||
</div>
|
||||
<p id="load">Firebase SDK Loading…</p>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const loadEl = document.querySelector('#load');
|
||||
// // 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥
|
||||
// // The Firebase SDK is initialized and available here!
|
||||
//
|
||||
// firebase.auth().onAuthStateChanged(user => { });
|
||||
// firebase.database().ref('/path/to/ref').on('value', snapshot => { });
|
||||
// firebase.firestore().doc('/foo/bar').get().then(() => { });
|
||||
// firebase.functions().httpsCallable('yourFunction')().then(() => { });
|
||||
// firebase.messaging().requestPermission().then(() => { });
|
||||
// firebase.storage().ref('/path/to/ref').getDownloadURL().then(() => { });
|
||||
// firebase.analytics(); // call to activate
|
||||
// firebase.analytics().logEvent('tutorial_completed');
|
||||
// firebase.performance(); // call to activate
|
||||
//
|
||||
// // 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥
|
||||
|
||||
try {
|
||||
let app = firebase.app();
|
||||
let features = [
|
||||
'auth',
|
||||
'database',
|
||||
'firestore',
|
||||
'functions',
|
||||
'messaging',
|
||||
'storage',
|
||||
'analytics',
|
||||
'remoteConfig',
|
||||
'performance',
|
||||
].filter(feature => typeof app[feature] === 'function');
|
||||
loadEl.textContent = `Firebase SDK loaded with ${features.join(', ')}`;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
loadEl.textContent = 'Error loading the Firebase SDK, check the console.';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
32
src/index.ts
Normal file
32
src/index.ts
Normal file
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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 {onRequest} from "firebase-functions/https";
|
||||
import * as logger from "firebase-functions/logger";
|
||||
|
||||
// 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!");
|
||||
// });
|
||||
96
src/kbo/aspnet-client.ts
Normal file
96
src/kbo/aspnet-client.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { extractHiddenField } from "./html-utils.js";
|
||||
|
||||
export const PREFIX =
|
||||
"ctl00$ctl00$ctl00$cphContents$cphContents$cphContents$";
|
||||
|
||||
export interface PageState {
|
||||
viewState: string;
|
||||
viewStateGenerator: string;
|
||||
eventValidation: string;
|
||||
}
|
||||
|
||||
export interface PostbackRequest {
|
||||
url: string;
|
||||
eventTarget: string;
|
||||
scriptManager: string;
|
||||
formFields: Record<string, string>;
|
||||
state: PageState;
|
||||
}
|
||||
|
||||
export interface PostbackResponse {
|
||||
html: string;
|
||||
newState: PageState;
|
||||
}
|
||||
|
||||
const USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/147.0.0.0 Safari/537.36";
|
||||
|
||||
export async function getInitialPageState(
|
||||
url: string
|
||||
): Promise<{ html: string; state: PageState }> {
|
||||
const res = await fetch(url, {
|
||||
headers: { "User-Agent": USER_AGENT },
|
||||
});
|
||||
const html = await res.text();
|
||||
|
||||
return {
|
||||
html,
|
||||
state: {
|
||||
viewState: extractHiddenField(html, "__VIEWSTATE"),
|
||||
viewStateGenerator: extractHiddenField(html, "__VIEWSTATEGENERATOR"),
|
||||
eventValidation: extractHiddenField(html, "__EVENTVALIDATION"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function postback(
|
||||
request: PostbackRequest
|
||||
): Promise<PostbackResponse> {
|
||||
const formData = new URLSearchParams();
|
||||
|
||||
formData.set(`${PREFIX}ScriptManager`, request.scriptManager);
|
||||
|
||||
for (const [key, value] of Object.entries(request.formFields)) {
|
||||
formData.set(key, value);
|
||||
}
|
||||
|
||||
formData.set("__EVENTTARGET", request.eventTarget);
|
||||
formData.set("__EVENTARGUMENT", "");
|
||||
formData.set("__LASTFOCUS", "");
|
||||
formData.set("__VIEWSTATE", request.state.viewState);
|
||||
formData.set("__VIEWSTATEGENERATOR", request.state.viewStateGenerator);
|
||||
formData.set("__EVENTVALIDATION", request.state.eventValidation);
|
||||
formData.set("__ASYNCPOST", "true");
|
||||
|
||||
const res = await fetch(request.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"X-MicrosoftAjax": "Delta=true",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"User-Agent": USER_AGENT,
|
||||
Referer: request.url,
|
||||
Origin: new URL(request.url).origin,
|
||||
},
|
||||
body: formData.toString(),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
const panelMatch = text.match(
|
||||
/updatePanel\|[^|]*\|([\s\S]*?)\|[0-9]+\|hiddenField\|/
|
||||
);
|
||||
const html = panelMatch?.[1] ?? text;
|
||||
|
||||
const vsMatch = text.match(/__VIEWSTATE\|([^|]*)\|/);
|
||||
const vsgMatch = text.match(/__VIEWSTATEGENERATOR\|([^|]*)\|/);
|
||||
const evMatch = text.match(/__EVENTVALIDATION\|([^|]*)\|/);
|
||||
|
||||
const newState: PageState = {
|
||||
viewState: vsMatch?.[1] ?? request.state.viewState,
|
||||
viewStateGenerator: vsgMatch?.[1] ?? request.state.viewStateGenerator,
|
||||
eventValidation: evMatch?.[1] ?? request.state.eventValidation,
|
||||
};
|
||||
|
||||
return { html, newState };
|
||||
}
|
||||
29
src/kbo/html-utils.ts
Normal file
29
src/kbo/html-utils.ts
Normal file
@ -0,0 +1,29 @@
|
||||
export function extractHiddenField(html: string, name: string): string {
|
||||
const re = new RegExp(`id="${name}"[^>]*value="([^"]*)"`, "i");
|
||||
const match = html.match(re);
|
||||
if (match) return match[1];
|
||||
const re2 = new RegExp(`name="${name}"[^>]*value="([^"]*)"`, "i");
|
||||
const match2 = html.match(re2);
|
||||
return match2?.[1] ?? "";
|
||||
}
|
||||
|
||||
export function stripTags(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, "").trim();
|
||||
}
|
||||
|
||||
export function decodeHtmlEntities(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
|
||||
}
|
||||
|
||||
export function padCell(val: string, w: number): string {
|
||||
const displayWidth = [...val].reduce(
|
||||
(sum, ch) => sum + (ch.charCodeAt(0) > 0x7f ? 2 : 1),
|
||||
0
|
||||
);
|
||||
return val + " ".repeat(Math.max(0, w - displayWidth));
|
||||
}
|
||||
210
src/kbo/team-rank.ts
Normal file
210
src/kbo/team-rank.ts
Normal file
@ -0,0 +1,210 @@
|
||||
import { stripTags, decodeHtmlEntities } from "./html-utils.js";
|
||||
import {
|
||||
PREFIX,
|
||||
postback,
|
||||
getInitialPageState,
|
||||
type PageState,
|
||||
type PostbackResponse,
|
||||
} from "./aspnet-client.js";
|
||||
|
||||
const PAGE_URL =
|
||||
"https://www.koreabaseball.com/Record/TeamRank/TeamRank.aspx";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface TeamRank {
|
||||
rank: string;
|
||||
team: string;
|
||||
games: string;
|
||||
wins: string;
|
||||
losses: string;
|
||||
draws: string;
|
||||
winRate: string;
|
||||
gamesBehind: string;
|
||||
last10: string;
|
||||
streak: string;
|
||||
home: string;
|
||||
away: string;
|
||||
}
|
||||
|
||||
export type WinLossDraw = [win: number, loss: number, draw: number];
|
||||
|
||||
export interface TeamVsRecord {
|
||||
team: string;
|
||||
headToHead: Record<string, WinLossDraw>;
|
||||
total: WinLossDraw;
|
||||
}
|
||||
|
||||
export interface TeamRankResult {
|
||||
year: number;
|
||||
teams: TeamRank[];
|
||||
vsRecords: TeamVsRecord[];
|
||||
}
|
||||
|
||||
// ── Parsing ──
|
||||
|
||||
export function parseWLD(s: string): WinLossDraw {
|
||||
const parts = s.split("-").map(Number);
|
||||
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
||||
}
|
||||
|
||||
export function parseRankTable(html: string): TeamRank[] {
|
||||
const teams: TeamRank[] = [];
|
||||
|
||||
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])));
|
||||
}
|
||||
|
||||
if (tds.length >= 10) {
|
||||
const teamName = tds[1];
|
||||
if (/^[A-Z가-힣]/.test(teamName)) {
|
||||
teams.push({
|
||||
rank: tds[0],
|
||||
team: tds[1],
|
||||
games: tds[2],
|
||||
wins: tds[3],
|
||||
losses: tds[4],
|
||||
draws: tds[5],
|
||||
winRate: tds[6],
|
||||
gamesBehind: tds[7],
|
||||
last10: tds[8],
|
||||
streak: tds[9],
|
||||
home: tds[10] ?? "",
|
||||
away: tds[11] ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return teams;
|
||||
}
|
||||
|
||||
export 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 team = cells[0];
|
||||
const headToHead: Record<string, WinLossDraw> = {};
|
||||
|
||||
for (let i = 0; i < teamNames.length; i++) {
|
||||
const val = cells[i + 1];
|
||||
if (val === "■" || val === "▲" || team === teamNames[i]) continue;
|
||||
headToHead[teamNames[i]] = parseWLD(val);
|
||||
}
|
||||
|
||||
const total = parseWLD(cells[cells.length - 1]);
|
||||
records.push({ team, headToHead, total });
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
// ── Page-specific helpers ──
|
||||
|
||||
export function extractCurrentYear(html: string): number {
|
||||
const m = html.match(
|
||||
/id="cphContents_cphContents_cphContents_lblSearchDateTitle">(\d{4})/
|
||||
);
|
||||
return m ? parseInt(m[1], 10) : new Date().getFullYear();
|
||||
}
|
||||
|
||||
function buildYearFormFields(
|
||||
year: number,
|
||||
currentYear: number
|
||||
): Record<string, string> {
|
||||
return {
|
||||
[`${PREFIX}ddlYear`]: String(year),
|
||||
[`${PREFIX}ddlSeries`]: "0",
|
||||
[`${PREFIX}hfSearchYear`]: String(currentYear),
|
||||
[`${PREFIX}hfSearchDate`]: `${currentYear}1231`,
|
||||
[`${PREFIX}hfSearchSeries`]: "0",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Fetch ──
|
||||
|
||||
export async function fetchTeamRank(
|
||||
year: number,
|
||||
state: PageState,
|
||||
currentYear: number
|
||||
): Promise<{ result: TeamRankResult; newState: PageState }> {
|
||||
const { html, newState }: PostbackResponse = await postback({
|
||||
url: PAGE_URL,
|
||||
eventTarget: `${PREFIX}ddlYear`,
|
||||
scriptManager: `${PREFIX}udpRecord|${PREFIX}ddlYear`,
|
||||
formFields: buildYearFormFields(year, currentYear),
|
||||
state,
|
||||
});
|
||||
|
||||
return {
|
||||
result: {
|
||||
year,
|
||||
teams: parseRankTable(html),
|
||||
vsRecords: parseVsTable(html),
|
||||
},
|
||||
newState,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchTeamRankInitial(): Promise<{
|
||||
result: TeamRankResult;
|
||||
state: PageState;
|
||||
currentYear: number;
|
||||
}> {
|
||||
const { html, state } = await getInitialPageState(PAGE_URL);
|
||||
const currentYear = extractCurrentYear(html);
|
||||
|
||||
return {
|
||||
result: {
|
||||
year: currentYear,
|
||||
teams: parseRankTable(html),
|
||||
vsRecords: parseVsTable(html),
|
||||
},
|
||||
state,
|
||||
currentYear,
|
||||
};
|
||||
}
|
||||
9
storage.rules
Normal file
9
storage.rules
Normal file
@ -0,0 +1,9 @@
|
||||
rules_version = '2';
|
||||
|
||||
service firebase.storage {
|
||||
match /b/{bucket}/o {
|
||||
match /{allPaths=**} {
|
||||
allow read, write: if request.auth != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
5
tsconfig.dev.json
Normal file
5
tsconfig.dev.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"include": [
|
||||
".eslintrc.js"
|
||||
]
|
||||
}
|
||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "nodenext",
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"outDir": "lib",
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"target": "es2017"
|
||||
},
|
||||
"compileOnSave": true,
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user