567 lines
20 KiB
JavaScript
567 lines
20 KiB
JavaScript
// 자동매매 페이지 클라이언트 스크립트
|
||
|
||
let logLevelFilter = 'all'; // 'all' | 'action'
|
||
let watchSource = { useScanner: true, selectedThemes: [] };
|
||
let localLogs = []; // 클라이언트 로그 버퍼 (오래된 것 → 최신 순)
|
||
let tradeLogWS = null;
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
loadStatus();
|
||
loadPositions();
|
||
loadLogs(); // 기존 로그 초기 로드 (HTTP)
|
||
loadRules();
|
||
loadThemeList();
|
||
loadWatchSource();
|
||
connectTradeLogWS(); // WS 실시간 연결
|
||
|
||
// 자동스크롤 체크박스: ON 전환 시 즉시 최신으로 이동
|
||
const chk = document.getElementById('autoScrollChk');
|
||
if (chk) {
|
||
chk.addEventListener('change', () => {
|
||
if (chk.checked) scrollLogsToBottom();
|
||
});
|
||
}
|
||
|
||
// 상태: 3초 주기, 포지션: 5초 주기 (로그는 WS로 대체)
|
||
setInterval(loadStatus, 3000);
|
||
setInterval(loadPositions, 5000);
|
||
});
|
||
|
||
// --- 엔진 상태 ---
|
||
|
||
async function loadStatus() {
|
||
try {
|
||
const res = await fetch('/api/autotrade/status');
|
||
const data = await res.json();
|
||
updateStatusUI(data);
|
||
} catch (e) {
|
||
console.error('상태 조회 실패:', e);
|
||
}
|
||
}
|
||
|
||
function updateStatusUI(data) {
|
||
const dot = document.getElementById('statusDot');
|
||
const text = document.getElementById('statusText');
|
||
|
||
if (data.running) {
|
||
dot.className = 'w-3 h-3 rounded-full bg-green-500';
|
||
text.textContent = '● 실행중';
|
||
text.className = 'text-sm font-medium text-green-600';
|
||
} else {
|
||
dot.className = 'w-3 h-3 rounded-full bg-gray-300';
|
||
text.textContent = '○ 중지';
|
||
text.className = 'text-sm font-medium text-gray-500';
|
||
}
|
||
|
||
document.getElementById('statActivePos').textContent = data.activePositions ?? 0;
|
||
document.getElementById('statTradeCount').textContent = data.tradeCount ?? 0;
|
||
|
||
const pl = data.totalPL ?? 0;
|
||
const plEl = document.getElementById('statTotalPL');
|
||
plEl.textContent = formatMoney(pl) + '원';
|
||
plEl.className = 'text-2xl font-bold ' + (pl > 0 ? 'text-red-500' : pl < 0 ? 'text-blue-500' : 'text-gray-800');
|
||
}
|
||
|
||
// --- 엔진 제어 ---
|
||
|
||
async function startEngine() {
|
||
if (!confirm('자동매매 엔진을 시작하시겠습니까?')) return;
|
||
try {
|
||
await fetch('/api/autotrade/start', { method: 'POST' });
|
||
await loadStatus();
|
||
} catch (e) {
|
||
alert('엔진 시작 실패: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function stopEngine() {
|
||
if (!confirm('자동매매 엔진을 중지하시겠습니까?')) return;
|
||
try {
|
||
await fetch('/api/autotrade/stop', { method: 'POST' });
|
||
await loadStatus();
|
||
} catch (e) {
|
||
alert('엔진 중지 실패: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function emergencyStop() {
|
||
if (!confirm('⚠ 긴급청산: 모든 포지션을 즉시 시장가 매도합니다.\n계속하시겠습니까?')) return;
|
||
try {
|
||
await fetch('/api/autotrade/emergency', { method: 'POST' });
|
||
await loadStatus();
|
||
await loadPositions();
|
||
} catch (e) {
|
||
alert('긴급청산 실패: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// --- 규칙 ---
|
||
|
||
async function loadRules() {
|
||
try {
|
||
const res = await fetch('/api/autotrade/rules');
|
||
const rules = await res.json();
|
||
renderRules(rules);
|
||
} catch (e) {
|
||
console.error('규칙 조회 실패:', e);
|
||
}
|
||
}
|
||
|
||
function renderRules(rules) {
|
||
const el = document.getElementById('rulesList');
|
||
if (!rules || rules.length === 0) {
|
||
el.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">규칙이 없습니다.</p>';
|
||
return;
|
||
}
|
||
el.innerHTML = rules.map(r => `
|
||
<div class="border border-gray-100 rounded-lg p-3 space-y-2">
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-sm font-semibold text-gray-800">${escHtml(r.name)}</span>
|
||
<label class="inline-flex items-center cursor-pointer">
|
||
<input type="checkbox" ${r.enabled ? 'checked' : ''} onchange="toggleRule('${r.id}')"
|
||
class="sr-only peer">
|
||
<div class="w-9 h-5 bg-gray-200 peer-checked:bg-blue-600 rounded-full peer
|
||
peer-focus:ring-2 peer-focus:ring-blue-300 transition-colors relative
|
||
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
|
||
after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all
|
||
peer-checked:after:translate-x-4"></div>
|
||
</label>
|
||
</div>
|
||
<div class="text-xs text-gray-500 space-y-0.5">
|
||
<p>진입: RiseScore≥${r.minRiseScore} / 체결강도≥${r.minCntrStr}${r.requireBullish ? ' / AI호재' : ''}</p>
|
||
<p>청산: 손절${r.stopLossPct}% / 익절+${r.takeProfitPct}%${r.maxHoldMinutes > 0 ? ' / ' + r.maxHoldMinutes + '분' : ''}${r.exitBeforeClose ? ' / 장마감전' : ''}</p>
|
||
<p>주문금액: ${formatMoney(r.orderAmount)}원 / 최대${r.maxPositions}종목</p>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<button onclick="showEditRuleModal(${JSON.stringify(r).replace(/"/g, '"')})"
|
||
class="px-2 py-1 text-xs text-blue-600 border border-blue-200 rounded hover:bg-blue-50 transition-colors">수정</button>
|
||
<button onclick="deleteRule('${r.id}')"
|
||
class="px-2 py-1 text-xs text-red-500 border border-red-200 rounded hover:bg-red-50 transition-colors">삭제</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
async function deleteRule(id) {
|
||
if (!confirm('규칙을 삭제하시겠습니까?')) return;
|
||
try {
|
||
await fetch('/api/autotrade/rules/' + id, { method: 'DELETE' });
|
||
await loadRules();
|
||
} catch (e) {
|
||
alert('삭제 실패: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function toggleRule(id) {
|
||
try {
|
||
await fetch('/api/autotrade/rules/' + id + '/toggle', { method: 'POST' });
|
||
await loadRules();
|
||
} catch (e) {
|
||
alert('토글 실패: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// --- 모달 ---
|
||
|
||
function showAddRuleModal() {
|
||
document.getElementById('modalTitle').textContent = '규칙 추가';
|
||
document.getElementById('ruleId').value = '';
|
||
document.getElementById('fName').value = '';
|
||
document.getElementById('fRiseScore').value = 60;
|
||
document.getElementById('riseScoreVal').textContent = '60';
|
||
document.getElementById('fCntrStr').value = 110;
|
||
document.getElementById('fRequireBullish').checked = false;
|
||
document.getElementById('fOrderAmount').value = 500000;
|
||
document.getElementById('fMaxPositions').value = 3;
|
||
document.getElementById('fStopLoss').value = -3;
|
||
document.getElementById('fTakeProfit').value = 5;
|
||
document.getElementById('fMaxHold').value = 60;
|
||
document.getElementById('fExitBeforeClose').checked = true;
|
||
document.getElementById('ruleModal').classList.remove('hidden');
|
||
}
|
||
|
||
function showEditRuleModal(r) {
|
||
document.getElementById('modalTitle').textContent = '규칙 수정';
|
||
document.getElementById('ruleId').value = r.id;
|
||
document.getElementById('fName').value = r.name;
|
||
document.getElementById('fRiseScore').value = r.minRiseScore;
|
||
document.getElementById('riseScoreVal').textContent = r.minRiseScore;
|
||
document.getElementById('fCntrStr').value = r.minCntrStr;
|
||
document.getElementById('fRequireBullish').checked = r.requireBullish;
|
||
document.getElementById('fOrderAmount').value = r.orderAmount;
|
||
document.getElementById('fMaxPositions').value = r.maxPositions;
|
||
document.getElementById('fStopLoss').value = r.stopLossPct;
|
||
document.getElementById('fTakeProfit').value = r.takeProfitPct;
|
||
document.getElementById('fMaxHold').value = r.maxHoldMinutes;
|
||
document.getElementById('fExitBeforeClose').checked = r.exitBeforeClose;
|
||
document.getElementById('ruleModal').classList.remove('hidden');
|
||
}
|
||
|
||
function hideModal() {
|
||
document.getElementById('ruleModal').classList.add('hidden');
|
||
}
|
||
|
||
async function submitRule() {
|
||
const id = document.getElementById('ruleId').value;
|
||
const rule = {
|
||
name: document.getElementById('fName').value.trim(),
|
||
enabled: true,
|
||
minRiseScore: parseInt(document.getElementById('fRiseScore').value),
|
||
minCntrStr: parseFloat(document.getElementById('fCntrStr').value),
|
||
requireBullish: document.getElementById('fRequireBullish').checked,
|
||
orderAmount: parseInt(document.getElementById('fOrderAmount').value),
|
||
maxPositions: parseInt(document.getElementById('fMaxPositions').value),
|
||
stopLossPct: parseFloat(document.getElementById('fStopLoss').value),
|
||
takeProfitPct: parseFloat(document.getElementById('fTakeProfit').value),
|
||
maxHoldMinutes: parseInt(document.getElementById('fMaxHold').value),
|
||
exitBeforeClose: document.getElementById('fExitBeforeClose').checked,
|
||
};
|
||
|
||
if (!rule.name) { alert('규칙명을 입력해주세요.'); return; }
|
||
|
||
try {
|
||
if (id) {
|
||
await fetch('/api/autotrade/rules/' + id, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(rule),
|
||
});
|
||
} else {
|
||
await fetch('/api/autotrade/rules', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(rule),
|
||
});
|
||
}
|
||
hideModal();
|
||
await loadRules();
|
||
} catch (e) {
|
||
alert('저장 실패: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// --- 포지션 ---
|
||
|
||
async function loadPositions() {
|
||
try {
|
||
const res = await fetch('/api/autotrade/positions');
|
||
const positions = await res.json();
|
||
renderPositions(positions);
|
||
} catch (e) {
|
||
console.error('포지션 조회 실패:', e);
|
||
}
|
||
}
|
||
|
||
function renderPositions(positions) {
|
||
const el = document.getElementById('positionsList');
|
||
const active = (positions || []).filter(p => p.status === 'open' || p.status === 'pending');
|
||
|
||
if (active.length === 0) {
|
||
el.innerHTML = '<p class="text-xs text-gray-400 text-center py-4">보유 포지션 없음</p>';
|
||
return;
|
||
}
|
||
|
||
el.innerHTML = `
|
||
<table class="w-full text-xs">
|
||
<thead>
|
||
<tr class="text-left text-gray-400 border-b border-gray-100">
|
||
<th class="pb-2 font-medium">종목</th>
|
||
<th class="pb-2 font-medium text-right">매수가</th>
|
||
<th class="pb-2 font-medium text-right">수량</th>
|
||
<th class="pb-2 font-medium text-right">손절가</th>
|
||
<th class="pb-2 font-medium text-center">상태</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${active.map(p => {
|
||
const statusCls = p.status === 'open' ? 'text-green-600' : 'text-yellow-600';
|
||
const statusTxt = p.status === 'open' ? '보유중' : '체결대기';
|
||
return `
|
||
<tr class="border-b border-gray-50">
|
||
<td class="py-2">
|
||
<div class="font-medium text-gray-800">${escHtml(p.name)}</div>
|
||
<div class="text-gray-400">${p.code}</div>
|
||
</td>
|
||
<td class="py-2 text-right text-gray-700">${formatMoney(p.buyPrice)}</td>
|
||
<td class="py-2 text-right text-gray-700">${p.qty}</td>
|
||
<td class="py-2 text-right text-blue-500">${formatMoney(p.stopLoss)}</td>
|
||
<td class="py-2 text-center font-medium ${statusCls}">${statusTxt}</td>
|
||
</tr>
|
||
`;
|
||
}).join('')}
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
}
|
||
|
||
// --- 감시 소스 ---
|
||
|
||
async function loadThemeList() {
|
||
try {
|
||
const res = await fetch('/api/themes?date=1&sort=3');
|
||
const themes = await res.json();
|
||
const sel = document.getElementById('themeSelect');
|
||
if (!sel || !themes) return;
|
||
// 가나다순 정렬 후 추가
|
||
const sorted = [...themes].sort((a, b) => a.name.localeCompare(b.name, 'ko'));
|
||
sel.innerHTML = '<option value="">테마를 선택하세요...</option>';
|
||
sorted.forEach(t => {
|
||
const opt = document.createElement('option');
|
||
opt.value = t.code;
|
||
opt.textContent = `${t.name} (${t.fluRt >= 0 ? '+' : ''}${t.fluRt.toFixed(2)}%)`;
|
||
opt.dataset.name = t.name;
|
||
sel.appendChild(opt);
|
||
});
|
||
} catch (e) {
|
||
console.error('테마 목록 조회 실패:', e);
|
||
}
|
||
}
|
||
|
||
async function loadWatchSource() {
|
||
try {
|
||
const res = await fetch('/api/autotrade/watch-source');
|
||
watchSource = await res.json();
|
||
renderWatchSource();
|
||
} catch (e) {
|
||
console.error('감시 소스 조회 실패:', e);
|
||
}
|
||
}
|
||
|
||
async function saveWatchSource() {
|
||
const scannerChk = document.getElementById('wsScanner');
|
||
watchSource.useScanner = scannerChk ? scannerChk.checked : true;
|
||
try {
|
||
await fetch('/api/autotrade/watch-source', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(watchSource),
|
||
});
|
||
} catch (e) {
|
||
alert('감시 소스 저장 실패: ' + e.message);
|
||
}
|
||
}
|
||
|
||
function renderWatchSource() {
|
||
const scannerChk = document.getElementById('wsScanner');
|
||
if (scannerChk) scannerChk.checked = watchSource.useScanner;
|
||
|
||
const container = document.getElementById('selectedThemes');
|
||
const noMsg = document.getElementById('noThemeMsg');
|
||
if (!container) return;
|
||
|
||
const themes = watchSource.selectedThemes || [];
|
||
if (themes.length === 0) {
|
||
container.innerHTML = '<span class="text-xs text-gray-400" id="noThemeMsg">선택된 테마 없음</span>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = themes.map(t => `
|
||
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-700">
|
||
${escHtml(t.name)}
|
||
<button onclick="removeTheme('${escHtml(t.code)}')" class="hover:text-blue-900 text-blue-400 font-bold leading-none">×</button>
|
||
</span>
|
||
`).join('');
|
||
}
|
||
|
||
function addSelectedTheme() {
|
||
const sel = document.getElementById('themeSelect');
|
||
if (!sel || !sel.value) return;
|
||
const code = sel.value;
|
||
const name = sel.options[sel.selectedIndex]?.dataset.name || sel.options[sel.selectedIndex]?.text || code;
|
||
|
||
if (!watchSource.selectedThemes) watchSource.selectedThemes = [];
|
||
if (watchSource.selectedThemes.some(t => t.code === code)) {
|
||
alert('이미 추가된 테마입니다.');
|
||
return;
|
||
}
|
||
watchSource.selectedThemes.push({ code, name });
|
||
renderWatchSource();
|
||
saveWatchSource();
|
||
sel.value = '';
|
||
}
|
||
|
||
function removeTheme(code) {
|
||
if (!watchSource.selectedThemes) return;
|
||
watchSource.selectedThemes = watchSource.selectedThemes.filter(t => t.code !== code);
|
||
renderWatchSource();
|
||
saveWatchSource();
|
||
}
|
||
|
||
// --- 로그 ---
|
||
|
||
// --- WS 실시간 로그 ---
|
||
|
||
function connectTradeLogWS() {
|
||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
tradeLogWS = new WebSocket(`${proto}//${location.host}/ws`);
|
||
|
||
tradeLogWS.onopen = () => {
|
||
updateWSStatus(true);
|
||
};
|
||
|
||
tradeLogWS.onmessage = (e) => {
|
||
let msg;
|
||
try { msg = JSON.parse(e.data); } catch { return; }
|
||
if (msg.type !== 'tradelog') return;
|
||
const log = msg.data;
|
||
localLogs.push(log);
|
||
if (localLogs.length > 300) localLogs.shift();
|
||
|
||
// 필터 조건에 맞으면 DOM에 행 추가
|
||
if (logLevelFilter !== 'action' || log.level !== 'debug') {
|
||
appendLogRow(log);
|
||
}
|
||
updateLogTime();
|
||
};
|
||
|
||
tradeLogWS.onclose = () => {
|
||
updateWSStatus(false);
|
||
// 3초 후 재연결 + 로그 재로드
|
||
setTimeout(() => {
|
||
loadLogs();
|
||
connectTradeLogWS();
|
||
}, 3000);
|
||
};
|
||
|
||
tradeLogWS.onerror = () => {
|
||
tradeLogWS.close();
|
||
};
|
||
}
|
||
|
||
function updateWSStatus(connected) {
|
||
const el = document.getElementById('wsStatus');
|
||
if (!el) return;
|
||
el.textContent = connected ? '● 실시간' : '○ 연결중...';
|
||
el.className = connected
|
||
? 'text-xs text-green-500 font-medium'
|
||
: 'text-xs text-gray-400';
|
||
}
|
||
|
||
function scrollLogsToBottom() {
|
||
const wrapper = document.getElementById('logsWrapper');
|
||
if (wrapper) wrapper.scrollTop = wrapper.scrollHeight;
|
||
}
|
||
|
||
function updateLogTime() {
|
||
const el = document.getElementById('logUpdateTime');
|
||
if (el) el.textContent = new Date().toLocaleTimeString('ko-KR',
|
||
{ hour: '2-digit', minute: '2-digit', second: '2-digit' }) + ' 갱신';
|
||
}
|
||
|
||
function buildLogRow(l) {
|
||
let rowCls, levelCls, msgCls;
|
||
if (l.level === 'debug') {
|
||
rowCls = 'border-b border-gray-50 bg-gray-50';
|
||
levelCls = 'text-gray-400';
|
||
msgCls = 'text-gray-400';
|
||
} else if (l.level === 'error') {
|
||
rowCls = 'border-b border-gray-50 hover:bg-gray-50';
|
||
levelCls = 'text-red-500';
|
||
msgCls = 'text-gray-700';
|
||
} else if (l.level === 'warn') {
|
||
rowCls = 'border-b border-gray-50 hover:bg-gray-50';
|
||
levelCls = 'text-yellow-600';
|
||
msgCls = 'text-gray-700';
|
||
} else {
|
||
rowCls = 'border-b border-gray-50 hover:bg-gray-50';
|
||
levelCls = 'text-gray-500';
|
||
msgCls = 'text-gray-700';
|
||
}
|
||
const time = new Date(l.at).toLocaleTimeString('ko-KR',
|
||
{ hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
return `<tr class="${rowCls}">
|
||
<td class="py-1.5 px-1 text-gray-400">${time}</td>
|
||
<td class="py-1.5 px-1 font-medium ${levelCls}">${l.level}</td>
|
||
<td class="py-1.5 px-1 text-gray-600">${escHtml(l.code)}</td>
|
||
<td class="py-1.5 px-1 ${msgCls}">${escHtml(l.message)}</td>
|
||
</tr>`;
|
||
}
|
||
|
||
function appendLogRow(log) {
|
||
const tbody = document.getElementById('logsList');
|
||
if (!tbody) return;
|
||
|
||
// 빈 상태 메시지 제거
|
||
if (tbody.firstElementChild?.tagName === 'TR' &&
|
||
tbody.firstElementChild.querySelector('td[colspan]')) {
|
||
tbody.innerHTML = '';
|
||
}
|
||
|
||
tbody.insertAdjacentHTML('beforeend', buildLogRow(log));
|
||
|
||
// 표시 행 수 300개 초과 시 맨 위 행 제거
|
||
while (tbody.children.length > 300) tbody.removeChild(tbody.firstChild);
|
||
|
||
// 자동스크롤
|
||
const chk = document.getElementById('autoScrollChk');
|
||
if (chk && chk.checked) scrollLogsToBottom();
|
||
}
|
||
|
||
function setLogFilter(level) {
|
||
logLevelFilter = level;
|
||
|
||
const allBtn = document.getElementById('filterAll');
|
||
const actionBtn = document.getElementById('filterAction');
|
||
if (allBtn && actionBtn) {
|
||
if (level === 'all') {
|
||
allBtn.className = 'px-3 py-1 rounded-full bg-gray-800 text-white font-medium';
|
||
actionBtn.className = 'px-3 py-1 rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200';
|
||
} else {
|
||
allBtn.className = 'px-3 py-1 rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200';
|
||
actionBtn.className = 'px-3 py-1 rounded-full bg-gray-800 text-white font-medium';
|
||
}
|
||
}
|
||
|
||
// 버퍼에서 필터 적용 후 전체 재렌더
|
||
renderLogsFromBuffer();
|
||
}
|
||
|
||
async function loadLogs() {
|
||
try {
|
||
const res = await fetch('/api/autotrade/logs');
|
||
const logs = await res.json();
|
||
// 서버는 최신순으로 반환 → 뒤집어 오래된 것부터 저장
|
||
localLogs = (logs || []).slice(0, 300).reverse();
|
||
renderLogsFromBuffer();
|
||
} catch (e) {
|
||
console.error('로그 초기 로드 실패:', e);
|
||
}
|
||
}
|
||
|
||
function renderLogsFromBuffer() {
|
||
const tbody = document.getElementById('logsList');
|
||
if (!tbody) return;
|
||
|
||
const filtered = logLevelFilter === 'action'
|
||
? localLogs.filter(l => l.level !== 'debug')
|
||
: localLogs;
|
||
|
||
// 최근 300개 표시
|
||
const items = filtered.slice(-300);
|
||
|
||
if (items.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="4" class="py-4 text-center text-gray-400">로그가 없습니다.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = items.map(l => buildLogRow(l)).join('');
|
||
updateLogTime();
|
||
scrollLogsToBottom();
|
||
}
|
||
|
||
// --- 유틸 ---
|
||
|
||
function formatMoney(n) {
|
||
if (!n) return '0';
|
||
return n.toLocaleString('ko-KR');
|
||
}
|
||
|
||
function escHtml(str) {
|
||
if (!str) return '';
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|