Files
MBN_STOCK_WEBVIEW/Web/legacy-named-row-workflow.js

490 lines
19 KiB
JavaScript

(function (root, factory) {
"use strict";
const stockWorkflow = typeof module === "object" && module.exports
? require("./operator-workflow.js")
: root.MbnOperatorWorkflow;
const fixedWorkflow = typeof module === "object" && module.exports
? require("./legacy-fixed-workflow.js")
: root.MbnLegacyFixedWorkflow;
const themeWorkflow = typeof module === "object" && module.exports
? require("./theme-workflow.js")
: root.MbnThemeWorkflow;
const expertWorkflow = typeof module === "object" && module.exports
? require("./expert-workflow.js")
: root.MbnExpertWorkflow;
const comparisonWorkflow = typeof module === "object" && module.exports
? require("./comparison-workflow.js")
: root.MbnComparisonWorkflow;
const industryWorkflow = typeof module === "object" && module.exports
? require("./industry-workflow.js")
: root.MbnIndustryWorkflow;
const foreignIndexCandleWorkflow = typeof module === "object" && module.exports
? require("./legacy-foreign-index-candle-workflow.js")
: root.MbnLegacyForeignIndexCandleWorkflow;
const api = Object.freeze(factory(
stockWorkflow,
fixedWorkflow,
themeWorkflow,
expertWorkflow,
comparisonWorkflow,
industryWorkflow,
foreignIndexCandleWorkflow));
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnLegacyNamedRowWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function (
stockWorkflow,
fixedWorkflow,
themeWorkflow,
expertWorkflow,
comparisonWorkflow,
industryWorkflow,
foreignIndexCandleWorkflow) {
"use strict";
if (!stockWorkflow || !fixedWorkflow || !themeWorkflow || !expertWorkflow ||
!comparisonWorkflow || !industryWorkflow || !foreignIndexCandleWorkflow) {
throw new Error("Legacy named-row dependencies are unavailable.");
}
const selectionKeys = Object.freeze([
"groupCode", "subject", "graphicType", "subtype", "dataCode"
]);
const unsafeTextPattern = /[\u0000-\u001f\u007f\uFFFD]/;
const themeCodePattern = /^[0-9]{3,8}$/;
const expertCodePattern = /^[0-9]{4}$/;
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function isSafeField(value) {
return typeof value === "string" && value.length <= 256 &&
!unsafeTextPattern.test(value) && !value.includes("^");
}
function isSafeRawRow(value) {
return isPlainObject(value) && selectionKeys.every(key => isSafeField(value[key]));
}
function sameSelection(left, right) {
return isPlainObject(left) && isSafeRawRow(right) &&
selectionKeys.every(key => isSafeField(left[key]) && left[key] === right[key]);
}
function splitLegacyLabel(value) {
if (!isSafeField(value) || !value) return null;
const separator = value.indexOf("_");
return Object.freeze(separator < 0
? { first: value, remainder: "" }
: { first: value.slice(0, separator), remainder: value.slice(separator + 1) });
}
function stockLegacySignature(operator) {
const stock = operator?.stock;
const label = splitLegacyLabel(operator?.legacyLabel);
if (operator?.source !== "legacy-stock-workflow" || !isPlainObject(stock) || !label ||
!["kospi", "kosdaq"].includes(stock.market) || typeof stock.isNxt !== "boolean" ||
!isSafeField(stock.displayName) || !stock.displayName ||
!isSafeField(stock.stockCode) || !stock.stockCode) return null;
const baseGroup = stock.market === "kospi" ? "코스피" : "코스닥";
return Object.freeze({
groupCode: stock.isNxt ? `${baseGroup}_NXT` : baseGroup,
subject: stock.displayName,
graphicType: label.first,
subtype: label.remainder,
dataCode: stock.stockCode
});
}
function fixedIndexGroup(label) {
if (label.includes("코스피_NXT")) return "코스피_NXT";
if (label.includes("코스닥_NXT")) return "코스닥_NXT";
if (label.includes("코스피")) return "코스피";
if (label.includes("코스닥")) return "코스닥";
return "전체";
}
function fixedLegacySignature(value) {
const tab = value?.legacyTab ?? value?.market;
const section = value?.legacySection ?? value?.section;
const labelText = value?.legacyLabel ?? value?.label;
const label = splitLegacyLabel(labelText);
if (!label || !isSafeField(section) || !section ||
!["overseas", "exchange", "index"].includes(tab)) return null;
if (tab === "overseas" || tab === "exchange") {
return Object.freeze({
groupCode: tab === "overseas" ? "해외지수" : "환율",
subject: label.first,
graphicType: section,
subtype: label.remainder,
dataCode: ""
});
}
if (["5단 표그래프", "6종목 현재가", "12종목 현재가"].includes(section)) {
return Object.freeze({
groupCode: fixedIndexGroup(labelText),
subject: "지수",
graphicType: section,
subtype: labelText,
dataCode: ""
});
}
if (section === "매매동향") {
return Object.freeze({
groupCode: fixedIndexGroup(labelText),
subject: "지수",
graphicType: label.first,
subtype: label.remainder,
dataCode: ""
});
}
return Object.freeze({
groupCode: section,
subject: "지수",
graphicType: label.first,
subtype: label.remainder,
dataCode: ""
});
}
function themeLegacyDescriptor(rawRow, options = {}) {
const isNxt = rawRow?.groupCode === "테마_NXT";
if (!isSafeRawRow(rawRow) || (rawRow.groupCode !== "테마" && !isNxt) ||
!rawRow.subject || !themeCodePattern.test(rawRow.dataCode)) return null;
const match = /^테마-(현재가|예상체결가)(?:\((입력순|상승률순|하락률순)\))?$/.exec(rawRow.subtype);
if (!match) return null;
const valueKind = match[1] === "현재가" ? "CURRENT" : "EXPECTED";
if (isNxt && valueKind !== "CURRENT") return null;
// The original s5074/s5077/s5088 code falls through to its final
// "하락률순" branch when the persisted subtype has no parenthesized sort.
const sort = match[2]
? ({ 입력순: "INPUT_ORDER", 상승률순: "GAIN_DESC", 하락률순: "GAIN_ASC" })[match[2]]
: "GAIN_ASC";
const actionId = ({
"5단 표그래프|CURRENT": "five-row-current",
"5단 표그래프|EXPECTED": "five-row-expected",
"6종목 현재가|CURRENT": "six-row-current",
"12종목 현재가|CURRENT": "twelve-row-current"
})[`${rawRow.graphicType}|${valueKind}`];
if (!actionId) return null;
let title = rawRow.subject;
let session = null;
if (isNxt) {
const suffix = "(NXT)";
if (!title.endsWith(suffix) || title.length === suffix.length) return null;
title = title.slice(0, -suffix.length);
if (!isSafeField(title) || !title || title.includes(suffix)) return null;
const suppliedNow = options?.now;
const now = suppliedNow instanceof Date && Number.isFinite(suppliedNow.getTime())
? suppliedNow
: new Date();
// Original s5074/s5077/s5088 selects the NXT mark at scene construction:
// local hour 00..12 is pre-market and 13..23 is after-market.
session = now.getHours() <= 12 ? "PRE_MARKET" : "AFTER_MARKET";
}
return Object.freeze({
actionId,
sort,
theme: Object.freeze({
market: isNxt ? "nxt" : "krx",
themeCode: rawRow.dataCode,
title,
session,
itemCount: 1
})
});
}
function themeLegacySignature(operator) {
const theme = operator?.theme;
if (operator?.source !== "legacy-theme-workflow" || !isPlainObject(theme) ||
theme.market !== "krx" || !themeCodePattern.test(theme.themeCode) ||
!isSafeField(theme.title) || !theme.title) return null;
const action = ({
"five-row-current": ["5단 표그래프", "현재가"],
"five-row-expected": ["5단 표그래프", "예상체결가"],
"six-row-current": ["6종목 현재가", "현재가"],
"twelve-row-current": ["12종목 현재가", "현재가"]
})[operator.actionId];
const sortLabel = ({
INPUT_ORDER: "입력순",
GAIN_DESC: "상승률순",
GAIN_ASC: "하락률순"
})[operator.sort];
if (!action || !sortLabel) return null;
return Object.freeze({
groupCode: "테마",
subject: theme.title,
graphicType: action[0],
subtype: `테마-${action[1]}(${sortLabel})`,
dataCode: theme.themeCode
});
}
function expertLegacyDescriptor(rawRow) {
if (!isSafeRawRow(rawRow) || rawRow.groupCode !== "전문가 추천" ||
!rawRow.subject || rawRow.graphicType !== "5단 표그래프" ||
rawRow.subtype !== "현재가" || !expertCodePattern.test(rawRow.dataCode)) return null;
return Object.freeze({
expertCode: rawRow.dataCode,
expertName: rawRow.subject,
source: "oracle",
itemCount: 1,
previewTruncated: false
});
}
function expertLegacySignature(operator) {
const expert = operator?.expert;
if (operator?.source !== "legacy-expert-workflow" || !isPlainObject(expert) ||
!expertCodePattern.test(expert.expertCode) ||
!isSafeField(expert.expertName) || !expert.expertName) return null;
return Object.freeze({
groupCode: "전문가 추천",
subject: expert.expertName,
graphicType: "5단 표그래프",
subtype: "현재가",
dataCode: expert.expertCode
});
}
function comparisonLegacySignature(operator) {
if (operator?.source !== "legacy-comparison-workflow" ||
!Array.isArray(operator.pair) || operator.pair.length !== 2) return null;
const pair = comparisonWorkflow.normalizePair(operator.pair);
if (!pair) return null;
const subjects = pair.map(target => {
if (target.kind === "market-target") return target.displayName;
if (target.kind === "krx-stock") return target.stockName;
return null;
});
if (subjects.some(value => !isSafeField(value) || !value || value.includes(","))) return null;
if (["two-column-current", "two-column-expected"].includes(operator.actionId)) {
// The original "종목" row discarded each target's market. Only a pair of
// closed fixed selectors can be projected without creating a lossy row.
if (pair.some(target => target.kind !== "market-target")) return null;
const subject = subjects.join(",");
// UC1 persisted the generic "종목" group when neither label contained
// "지수" (for example an exchange-only pair). That row cannot prove it
// was not a same-name world stock, so do not create that lossy signature.
if (!subject.includes("지수")) return null;
return Object.freeze({
groupCode: "지수",
subject,
graphicType: "2열판",
subtype: operator.actionId === "two-column-expected" ? "예상체결" : "",
dataCode: ""
});
}
if (pair.some(target => target.kind !== "krx-stock")) return null;
const action = ({
"comparison-candle": ["종목별 비교분석_캔들 그래프", ""],
"comparison-line": ["종목별 비교분석_라인 그래프", ""],
"return-5d": ["종목별 수익률 비교", "5일"],
"return-1m": ["종목별 수익률 비교", "1개월"],
"return-3m": ["종목별 수익률 비교", "3개월"],
"return-6m": ["종목별 수익률 비교", "6개월"],
"return-12m": ["종목별 수익률 비교", "12개월"]
})[operator.actionId];
if (!action) return null;
return Object.freeze({
groupCode: pair.map(target => target.market === "kospi" ? "코스피" : "코스닥").join(","),
subject: subjects.join(","),
graphicType: action[0],
subtype: action[1],
dataCode: ""
});
}
function industryFixedDescriptor(rawRow) {
if (!isSafeRawRow(rawRow) || rawRow.subtype !== "" || rawRow.dataCode !== "") return null;
const market = ({
"업종_코스피": "kospi",
"업종_코스닥": "kosdaq"
})[rawRow.groupCode];
if (!market || rawRow.subject !== (market === "kospi" ? "코스피" : "코스닥")) return null;
const cutId = ({
"5단 표그래프": "five-row",
"네모그래프": "square",
"섹터지수": "sector"
})[rawRow.graphicType];
return cutId ? Object.freeze({ market, cutId }) : null;
}
function industryFixedLegacySignature(operator) {
if (operator?.source !== "legacy-industry-workflow" ||
!["kospi", "kosdaq"].includes(operator.market)) return null;
const graphicType = ({
"five-row": "5단 표그래프",
square: "네모그래프",
sector: "섹터지수"
})[operator.cutId];
if (!graphicType) return null;
return Object.freeze({
groupCode: operator.market === "kospi" ? "업종_코스피" : "업종_코스닥",
subject: operator.market === "kospi" ? "코스피" : "코스닥",
graphicType,
subtype: "",
dataCode: ""
});
}
function matchesFixedActionRaw(rawRow, action) {
return Boolean(action?.available && sameSelection(fixedLegacySignature(action), rawRow));
}
function matchesCanonicalEntry(entry, rawRow, options = {}) {
if (!isPlainObject(entry) || !isSafeRawRow(rawRow)) return false;
if (sameSelection(entry.selection, rawRow)) return true;
let signature = null;
switch (entry.operator?.source) {
case "legacy-stock-workflow":
signature = stockLegacySignature(entry.operator);
break;
case "legacy-fixed-workflow":
signature = fixedLegacySignature(entry.operator);
break;
case "legacy-theme-workflow":
if (entry.operator?.theme?.market === "nxt") {
const descriptor = themeLegacyDescriptor(rawRow, options);
return Boolean(descriptor && descriptor.theme.market === "nxt" &&
descriptor.actionId === entry.operator.actionId &&
descriptor.sort === entry.operator.sort &&
descriptor.theme.themeCode === entry.operator.theme.themeCode &&
descriptor.theme.title === entry.operator.theme.title &&
descriptor.theme.session === entry.operator.theme.session);
}
signature = themeLegacySignature(entry.operator);
if (sameSelection(signature, rawRow)) return true;
if (entry.operator.sort === "GAIN_ASC" && signature) {
const originalFallback = {
...signature,
subtype: signature.subtype.replace(/\(하락률순\)$/, "")
};
return sameSelection(originalFallback, rawRow);
}
break;
case "legacy-expert-workflow":
signature = expertLegacySignature(entry.operator);
break;
case "legacy-comparison-workflow":
signature = comparisonLegacySignature(entry.operator);
break;
case "legacy-industry-workflow":
signature = industryFixedLegacySignature(entry.operator);
break;
case "legacy-foreign-index-candle-workflow":
return foreignIndexCandleWorkflow.matchesRaw(entry, rawRow);
default:
return false;
}
return sameSelection(signature, rawRow);
}
function fixedRestoreNow(entry) {
const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(entry?.selection?.dataCode || "");
const now = dateMatch
? new Date(Number(dateMatch[1]), Number(dateMatch[2]) - 1, Number(dateMatch[3]), 9, 0, 0, 0)
: new Date();
if (entry?.selection?.graphicType === "PRE_MARKET") now.setHours(9, 0, 0, 0);
if (entry?.selection?.graphicType === "AFTER_MARKET") now.setHours(14, 0, 0, 0);
return now;
}
function restoreTrustedEntry(entry) {
try {
switch (entry?.operator?.source) {
case "legacy-stock-workflow":
return stockWorkflow.restoreStockPlaylistEntry(entry);
case "legacy-fixed-workflow":
return fixedWorkflow.restoreFixedPlaylistEntry(entry, { now: fixedRestoreNow(entry) });
case "legacy-theme-workflow":
return themeWorkflow.restoreThemePlaylistEntry(entry);
case "legacy-expert-workflow":
return expertWorkflow.restoreExpertPlaylistEntry(entry);
case "legacy-comparison-workflow":
return comparisonWorkflow.restoreComparisonPlaylistEntry(entry);
case "legacy-industry-workflow":
return industryWorkflow.restoreIndustryPlaylistEntry(entry);
case "legacy-foreign-index-candle-workflow":
return foreignIndexCandleWorkflow.restorePlaylistEntry(entry);
default:
return null;
}
} catch {
return null;
}
}
function legacySelectionForEntry(entry) {
if (!isPlainObject(entry) || !isPlainObject(entry.selection) ||
!selectionKeys.every(key => isSafeField(entry.selection[key]))) return null;
const trusted = restoreTrustedEntry(entry);
if (!trusted || trusted.enabled !== entry.enabled) return null;
switch (trusted.operator?.source) {
case "legacy-stock-workflow":
return stockLegacySignature(trusted.operator);
case "legacy-fixed-workflow":
return fixedLegacySignature(trusted.operator);
case "legacy-theme-workflow":
// The original NXT row did not persist PRE/AFTER session. Do not create
// a new lossy row that this closed restore contract must later block.
return themeLegacySignature(trusted.operator);
case "legacy-expert-workflow":
return expertLegacySignature(trusted.operator);
case "legacy-comparison-workflow":
return comparisonLegacySignature(trusted.operator);
case "legacy-industry-workflow":
return industryFixedLegacySignature(trusted.operator);
case "legacy-foreign-index-candle-workflow":
return foreignIndexCandleWorkflow.legacySelectionForEntry(trusted);
default:
return null;
}
}
function semanticKey(entry) {
const operator = entry?.operator;
return JSON.stringify({
builderKey: entry?.builderKey,
code: entry?.code,
selection: entry?.selection,
source: operator?.source ?? "",
actionId: operator?.actionId ?? "",
cutId: operator?.cutId ?? ""
});
}
function selectUniqueCanonicalEntry(candidates, rawRow, options = {}) {
if (!Array.isArray(candidates) || !isSafeRawRow(rawRow)) return null;
const matches = new Map();
for (const candidate of candidates) {
if (!matchesCanonicalEntry(candidate, rawRow, options)) continue;
const key = semanticKey(candidate);
if (!matches.has(key)) matches.set(key, candidate);
}
return matches.size === 1 ? [...matches.values()][0] : null;
}
return {
selectionKeys,
fixedLegacySignature,
comparisonLegacySignature,
industryFixedDescriptor,
industryFixedLegacySignature,
matchesFixedActionRaw,
themeLegacyDescriptor,
expertLegacyDescriptor,
matchesCanonicalEntry,
legacySelectionForEntry,
selectUniqueCanonicalEntry
};
});