490 lines
19 KiB
JavaScript
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
|
|
};
|
|
});
|