Data management improvements

This commit is contained in:
Sascha Leib
2025-09-05 12:47:36 +02:00
parent b2e3bd8b82
commit 259d3b850a
8 changed files with 136 additions and 99 deletions

View File

@@ -3,9 +3,13 @@
"id": "opera",
"rx": [ "\\sOpera\\/.*?Version\\/(\\S+)", "Opera\\/(\\S+)" ]
},
{"n": "AOL Explorer",
"id": "aol",
"rx": [ "\\sAOL\\s(\\d+\\.\\d+)\\)", "\\sAOLBUILD\\/(\\S+)", "\\sBukaolshop\\s", "\\.aolapp\\/(\\d+\\.\\d+)\\." ]
},
{"n": "Samsung Internet",
"id": "samsung",
"rx": [ "\\sSamsungBrowser\\/(\\S+)" ]
"rx": [ "\\sSamsungBrowser[\\/\\s](\\d+\\.\\d+)" ]
},
{"n": "UC Browser",
"id": "uc",

View File

@@ -19,9 +19,13 @@
"id": "android",
"rx": [ "[\\(\\s]Android\\s([^;]+);" ]
},
{"n": "MacOS (old)",
"id": "macosold",
"rx": [ "\\sMac OS X (10_\\d+_\\d+)" ]
},
{"n": "MacOS",
"id": "macos",
"rx": [ "\\(Macintosh;" ]
"rx": [ "\\sMac OS X (1[1-9]_\\d+_\\d+)" ]
},
{"n": "Vintage Windows",
"id": "winold",

View File

@@ -1,11 +1,29 @@
{
"ip-ranges": [
{"range": "191.177.0.0", "mask": 16, "bot": 50}
],
"threshold": 100,
"rules": [
{"field": "_client", "item": "id", "op": "equals", "value": "chromeold", "bot": 30}
],
"thresholds": {
"bot": 50
}
{"func": "obsoleteClient",
"id": "oldClient", "desc": "Visit with obsolete browser version",
"bot": 40
},
{"func": "obsoletePlatform",
"id": "oldOS", "desc": "Visit with obsolete platform version",
"bot": 40
},
{"func": "noJavaScript",
"id": "noJS", "desc": "Visit with JavaScript disabled",
"bot": 20
},
{"func": "smallPageCount", "params": [1],
"id": "onePage", "desc": "Views only a single page",
"bot": 20
},
{"func": "noTicks",
"id": "noTicks", "desc": "Visitor did not spend time reading any page",
"bot": 10
},
{"func": "noReferences",
"id": "noRefs", "desc": "None of the page views came with a reference field",
"bot": 30
}
]
}

BIN
img/aol.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 B

View File

@@ -1 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M21.873,7.119c-1.86,-3.743 -5.696,-6.119 -9.876,-6.119c-3.654,0 -7.078,1.815 -9.129,4.839l4.431,7.679c-0.15,-0.478 -0.227,-0.977 -0.227,-1.477c0,-2.675 2.181,-4.885 4.856,-4.922" style="fill:none;fill-rule:nonzero;stroke:#b87069;stroke-width:1.97px;"/><path d="M2.868,5.839c-1.218,1.816 -1.868,3.954 -1.868,6.14c0,5.714 4.443,10.529 10.14,10.99l4.628,-7.876c-0.935,1.133 -2.329,1.79 -3.798,1.79c-2.114,-0 -4.002,-1.361 -4.671,-3.365" style="fill:none;fill-rule:nonzero;stroke:#5aa65d;stroke-width:1.97px;"/><path d="M11.14,22.969c0.276,0.021 0.554,0.031 0.831,0.031c6.051,0 11.029,-4.977 11.029,-11.026c0,-1.683 -0.385,-3.344 -1.127,-4.855l-9.945,0c2.692,0.012 4.9,2.231 4.9,4.922c0,1.107 -0.373,2.183 -1.06,3.052" style="fill:none;fill-rule:nonzero;stroke:#ddc65c;stroke-width:1.97px;"/><path d="M11.946,8.536c1.882,-0 3.41,1.527 3.41,3.409c0,1.881 -1.528,3.409 -3.41,3.409c-1.882,-0 -3.41,-1.528 -3.41,-3.409c-0,-1.882 1.528,-3.409 3.41,-3.409Zm-0,1.597c-1.001,-0 -1.813,0.811 -1.813,1.812c-0,1 0.812,1.812 1.813,1.812c1.001,-0 1.813,-0.812 1.813,-1.812c0,-1.001 -0.812,-1.812 -1.813,-1.812Z" style="fill:#4080b0;"/></svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;"><path d="M19.51,14.165c0,-4.105 -3.378,-7.483 -7.483,-7.483c-4.105,-0 -7.484,3.378 -7.484,7.483l2.139,-0c-0,-2.932 2.412,-5.345 5.345,-5.345c2.932,0 5.345,2.413 5.345,5.345" style="fill-opacity:0.1;fill-rule:nonzero;"/><circle cx="11.973" cy="11.984" r="4.811" style="fill:#2e4b60;stroke:url(#_Radial1);stroke-width:0.96px;"/><path d="M22.717,6.682c-2.02,-4.065 -6.184,-6.645 -10.722,-6.645c-3.967,0 -7.684,1.971 -9.91,5.255l4.81,8.338c-0.163,-0.519 -0.246,-1.06 -0.246,-1.604c0,-2.904 2.367,-5.304 5.271,-5.344" style="fill:#74433c;fill-rule:nonzero;"/><path d="M2.085,5.292c-1.322,1.972 -2.028,4.293 -2.028,6.667c0,6.205 4.823,11.434 11.008,11.934l5.024,-8.552c-1.015,1.23 -2.528,1.943 -4.123,1.943c-2.295,0 -4.345,-1.477 -5.071,-3.654" style="fill:#356b34;fill-rule:nonzero;"/><path d="M11.065,23.893c0.3,0.023 0.601,0.034 0.902,0.034c6.569,0 11.973,-5.405 11.973,-11.973c0,-1.828 -0.418,-3.632 -1.223,-5.272l-10.797,0c2.923,0.013 5.32,2.422 5.32,5.345c0,1.202 -0.405,2.37 -1.151,3.314" style="fill:#877003;fill-rule:nonzero;"/><defs><radialGradient id="_Radial1" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(9.62138,0,0,9.62138,11.9733,7.17327)"><stop offset="0" style="stop-color:#f6f0ee;stop-opacity:1"/><stop offset="1" style="stop-color:#ddd;stop-opacity:1"/></radialGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

1
img/macos.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path id="path2" d="M11.153,14.569c-0,2.019 -1.052,3.29 -2.732,3.29c-1.68,-0 -2.732,-1.278 -2.732,-3.29c0,-2.025 1.052,-3.296 2.732,-3.296c1.68,-0 2.732,1.271 2.732,3.296Zm2.343,-7.122l-0.734,0.049c-0.416,0.029 -0.6,0.177 -0.6,0.445c0,0.283 0.233,0.438 0.558,0.438c0.445,-0 0.776,-0.29 0.776,-0.678l0,-0.254Zm10.504,4.553c0,6.699 -5.301,12 -12,12c-6.699,-0 -12,-5.301 -12,-12c0,-6.699 5.301,-12 12,-12c6.699,-0 12,5.301 12,12Zm-9.219,-4.701c0,0.974 0.537,1.567 1.412,1.567c0.741,-0 1.207,-0.417 1.285,-1.01l-0.579,0c-0.078,0.325 -0.332,0.502 -0.706,0.502c-0.494,-0 -0.798,-0.403 -0.798,-1.059c0,-0.65 0.304,-1.038 0.798,-1.038c0.395,0 0.642,0.226 0.706,0.515l0.579,0c-0.078,-0.578 -0.53,-1.016 -1.285,-1.016c-0.875,-0.007 -1.412,0.586 -1.412,1.539Zm-8.068,-1.483l-0,2.993l0.593,0l-0,-1.835c-0,-0.388 0.275,-0.699 0.635,-0.699c0.353,0 0.579,0.212 0.579,0.551l-0,1.983l0.579,0l-0,-1.891c-0,-0.36 0.247,-0.643 0.635,-0.643c0.388,0 0.579,0.198 0.579,0.614l-0,1.92l0.593,0l-0,-2.068c-0,-0.621 -0.353,-0.988 -0.96,-0.988c-0.417,-0 -0.762,0.212 -0.911,0.536l-0.049,0c-0.134,-0.324 -0.417,-0.536 -0.826,-0.536c-0.402,-0 -0.706,0.198 -0.833,0.536l-0.042,0l-0,-0.48l-0.572,0.007Zm5.492,8.753c-0,-2.604 -1.454,-4.242 -3.784,-4.242c-2.329,0 -3.783,1.638 -3.783,4.242c-0,2.605 1.454,4.236 3.783,4.236c2.33,-0 3.784,-1.638 3.784,-4.236Zm0.367,-5.71c0.395,-0 0.72,-0.17 0.903,-0.473l0.05,-0l-0,0.423l0.571,0l0,-2.047c0,-0.628 -0.423,-1.002 -1.178,-1.002c-0.685,0 -1.165,0.332 -1.229,0.833l0.572,-0c0.064,-0.219 0.297,-0.339 0.628,-0.339c0.403,0 0.615,0.184 0.615,0.515l-0,0.262l-0.812,0.049c-0.713,0.042 -1.116,0.353 -1.116,0.896c-0.007,0.537 0.417,0.883 0.996,0.883Zm6.473,7.475c-0,-1.165 -0.678,-1.842 -2.386,-2.216l-0.911,-0.198c-1.122,-0.247 -1.56,-0.692 -1.56,-1.334c0,-0.833 0.791,-1.334 1.814,-1.334c1.073,-0 1.779,0.55 1.871,1.454l1.023,-0c-0.049,-1.391 -1.228,-2.372 -2.865,-2.372c-1.702,0 -2.895,0.953 -2.895,2.301c0,1.165 0.713,1.913 2.358,2.273l0.911,0.198c1.136,0.247 1.595,0.706 1.595,1.39c-0,0.805 -0.812,1.391 -1.92,1.391c-1.179,0 -1.998,-0.536 -2.118,-1.419l-1.023,0c0.099,1.419 1.292,2.337 3.085,2.337c1.828,-0 3.021,-0.953 3.021,-2.471Z" style="fill:#1d1d1f;fill-rule:nonzero;"/></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

180
script.js
View File

@@ -209,6 +209,8 @@ BotMon.live = {
} else if (visitor._type == BM_USERTYPE.KNOWN_USER) { /* registered users */
//if (visitor.id == 'fsmoe7lgqb89t92vt4ju8vdl0q') console.log(visitor);
// visitors match when their names match:
if ( v.usr == visitor.usr
&& v.ip == visitor.ip
@@ -219,6 +221,9 @@ BotMon.live = {
if ( v.id == visitor.id) { /* match the pre-defined IDs */
return v;
} else if (v.ip == visitor.ip && v.agent == visitor.agent) {
console.info("Visitor ID not found, using matchin IP + User-Agent instead.");
return v;
}
}
@@ -226,18 +231,16 @@ BotMon.live = {
return null; // nothing found
},
/* if there is already this visit registered, return it (used for updates) */
_getVisit: function(visit, view) {
/* if there is already this visit registered, return the page view item */
_getPageView: function(visit, view) {
// shortcut to make code more readable:
const model = BotMon.live.data.model;
for (let i=0; i<visit._pageViews.length; i++) {
const pv = visit._pageViews[i];
if (pv.pg == view.pg && // same page id, and
view.ts.getTime() - pv._firstSeen.getTime() < 1200000) { // seen less than 20 minutes ago
return pv; // it is the same visit.
if (pv.pg == view.pg) {
return pv;
}
}
return null; // not found
@@ -255,9 +258,9 @@ BotMon.live = {
// enrich new visitor with relevant data:
if (!nv._bot) nv._bot = bot ?? null; // bot info
nv._type = ( bot ? BM_USERTYPE.KNOWN_BOT : ( nv.usr !== '' ? BM_USERTYPE.KNOWN_USER : BM_USERTYPE.UNKNOWN ) );
nv._type = ( bot ? BM_USERTYPE.KNOWN_BOT : ( nv.usr && nv.usr !== '' ? BM_USERTYPE.KNOWN_USER : BM_USERTYPE.UNKNOWN ) );
if (!nv._firstSeen) nv._firstSeen = nv.ts;
if (!nv._lastSeen) nv._lastSeen = nv.ts;
nv._lastSeen = nv.ts;
// check if it already exists:
let visitor = model.findVisitor(nv);
@@ -275,19 +278,16 @@ BotMon.live = {
// find browser
// is this visit already registered?
let prereg = model._getVisit(visitor, nv);
let prereg = model._getPageView(visitor, nv);
if (!prereg) {
// add the page view to the visitor:
prereg = {
_by: 'srv',
ip: nv.ip,
pg: nv.pg,
ref: nv.ref || '',
_firstSeen: nv.ts,
_lastSeen: nv.ts,
_jsClient: false
};
// add new page view:
prereg = model._makePageView(nv, type);
visitor._pageViews.push(prereg);
} else {
// update last seen date
prereg._lastSeen = nv.ts;
// increase view count:
prereg._viewCount += 1;
}
// update referrer state:
@@ -304,7 +304,6 @@ BotMon.live = {
// updating visit data from the client-side log:
updateVisit: function(dat) {
//console.info('updateVisit', dat);
return;
// shortcut to make code more readable:
const model = BotMon.live.data.model;
@@ -314,35 +313,26 @@ BotMon.live = {
let visitor = BotMon.live.data.model.findVisitor(dat);
if (!visitor) {
visitor = model.registerVisit(dat, type);
visitor._seenBy = [type];
}
if (visitor) {
// prime the "seen by" list:
seenBy = ( visitor._seenBy ? ( visitor._seenBy.includes(type) ? visitor._seenBy : [...visitor._seenBy, type] ) : [type] );
visitor._lastSeen = dat.ts;
visitor._seenBy = seenBy;
if (!visitor._seenBy.includes(type)) {
visitor._seenBy.push(type);
}
visitor._jsClient = true; // seen by client js
}
// find the page view:
let prereg = BotMon.live.data.model._getVisit(visitor, dat);
let prereg = BotMon.live.data.model._getPageView(visitor, dat);
if (prereg) {
// update the page view:
prereg._lastSeen = dat.ts;
if (!prereg._seenBy.includes(type)) prereg._seenBy.push(type);
prereg._jsClient = true; // seen by client js
} else {
// add the page view to the visitor:
prereg = {
_by: 'log',
ip: dat.ip,
pg: dat.pg,
ref: dat.ref || '',
_firstSeen: dat.ts,
_lastSeen: dat.ts,
_jsClient: true
};
prereg = model._makePageView(dat, type);
visitor._pageViews.push(prereg);
}
},
@@ -350,43 +340,53 @@ BotMon.live = {
// updating visit data from the ticker log:
updateTicks: function(dat) {
//console.info('updateTicks', dat);
return;
// shortcut to make code more readable:
const model = BotMon.live.data.model;
const type = 'tck';
// find the visit info:
let visitor = model.findVisitor(dat);
if (!visitor) {
//console.warn(`No visitor with ID ${dat.id}, registering a new one.`);
visitor = model.registerVisit(dat, 'tck');
console.warn(`No visitor with ID ${dat.id}, registering a new one.`);
visitor = model.registerVisit(dat, type);
}
if (visitor) {
// update visitor:
if (visitor._lastSeen < dat.ts) visitor._lastSeen = dat.ts;
if (!visitor._seenBy.includes('tck')) visitor._seenBy.push('tck');
if (!visitor._seenBy.includes(type)) visitor._seenBy.push(type);
// get the page view info:
const pv = model._getVisit(visitor, dat);
if (pv) {
// update the page view info:
if (pv._lastSeen.getTime() < dat.ts.getTime()) pv._lastSeen = dat.ts;
} else {
//console.warn(`No page view for visit ID ${dat.id}, page ${dat.pg}, registering a new one.`);
// add a new page view to the visitor:
const newPv = {
_by: 'tck',
ip: dat.ip,
pg: dat.pg,
ref: '',
_firstSeen: dat.ts,
_lastSeen: dat.ts,
_jsClient: false
};
visitor._pageViews.push(newPv);
let pv = model._getPageView(visitor, dat);
if (!pv) {
console.warn(`No page view for visit ID ${dat.id}, page ${dat.pg}, registering a new one.`);
pv = model._makePageView(dat, type);
visitor._pageViews.push(pv);
}
// update the page view info:
if (!pv._seenBy.includes(type)) pv._seenBy.push(type);
if (pv._lastSeen.getTime() < dat.ts.getTime()) pv._lastSeen = dat.ts;
pv._tickCount += 1;
}
},
// helper function to create a new "page view" item:
_makePageView: function(data, type) {
return {
_by: type,
ip: data.ip,
pg: data.pg,
ref: data.ref || '',
_firstSeen: data.ts,
_lastSeen: data.ts,
_seenBy: [type],
_jsClient: ( type !== 'srv'),
_viewCount: 1,
_tickCount: 0
};
}
},
@@ -422,7 +422,6 @@ BotMon.live = {
// shortcut to make code more readable:
const model = BotMon.live.data.model;
console.log(model._visitors);
// loop over all visitors:
model._visitors.forEach( (v) => {
@@ -494,24 +493,30 @@ BotMon.live = {
let botInfo = null;
// check for known bots:
if (agent) {
BotList.find(bot => {
let r = false;
for (let j=0; j<bot.rx.length; j++) {
const rxr = agent.match(new RegExp(bot.rx[j]));
if (rxr) {
botInfo = {
n : bot.n,
id: bot.id,
url: bot.url,
v: (rxr.length > 1 ? rxr[1] : -1)
};
r = true;
break;
}
};
return r;
});
BotList.find(bot => {
let r = false;
for (let j=0; j<bot.rx.length; j++) {
const rxr = agent.match(new RegExp(bot.rx[j]));
if (rxr) {
botInfo = {
n : bot.n,
id: bot.id,
url: bot.url,
v: (rxr.length > 1 ? rxr[1] : -1)
};
r = true;
break;
}
};
return r;
});
// check for unknown bots:
if (!botInfo) {
const botmatch = agent.match(/[^\s](\w*bot)[\/\s;\),$]/i);
if(botmatch) {
botInfo = {'id': "other", 'n': "Other", "bot": botmatch[0] };
}
}
//console.log("botInfo:", botInfo);
@@ -928,10 +933,11 @@ BotMon.live = {
if (data._type == BM_USERTYPE.KNOWN_BOT) { /* Bot only */
const botName = ( data._bot && data._bot.n ? data._bot.n : "Unknown");
span1.appendChild(make('span', { /* Bot */
'class': 'bot bot_' + (data._bot ? data._bot.id : 'unknown'),
'title': "Bot: " + (data._bot ? data._bot.n : 'Unknown')
}, (data._bot ? data._bot.n : 'Unknown')));
'title': "Bot: " + botName
}, botName));
} else if (data._type == BM_USERTYPE.KNOWN_USER) { /* User only */
@@ -1000,14 +1006,14 @@ BotMon.live = {
dl.appendChild(make('dt', {}, "Platform:")); /* platform */
dl.appendChild(make('dd', {'class': 'has_icon platform_' + (data._platform ? data._platform.id : 'unknown')},
platformName + ( data._platform.v > 0 ? ' (' + data._platform.v + ')' : '' ) ));
dl.appendChild(make('dt', {}, "IP-Address:"));
dl.appendChild(make('dd', {'class': 'has_icon ip' + ipType}, data.ip));
dl.appendChild(make('dt', {}, "ID:"));
dl.appendChild(make('dd', {'class': 'has_icon ip' + data.typ}, data.id));
}
dl.appendChild(make('dt', {}, "IP-Address:"));
dl.appendChild(make('dd', {'class': 'has_icon ip' + ipType}, data.ip));
dl.appendChild(make('dt', {}, "ID:"));
dl.appendChild(make('dd', {'class': 'has_icon ip' + data.typ}, data.id));
if ((data._lastSeen - data._firstSeen) < 1) {
dl.appendChild(make('dt', {}, "Seen:"));
dl.appendChild(make('dd', {'class': 'seen'}, data._firstSeen.toLocaleString()));
@@ -1041,8 +1047,10 @@ BotMon.live = {
}
pgLi.appendChild(make('span', {}, page.pg));
pgLi.appendChild(make('span', {}, page.ref));
pgLi.appendChild(make('span', {}, visitTimeStr));
// pgLi.appendChild(make('span', {}, page.ref));
pgLi.appendChild(make('span', {}, ( page._seenBy ? page._seenBy.join(', ') : '—') + '; ' + page._tickCount));
pgLi.appendChild(make('span', {}, page._firstSeen.toLocaleString()));
pgLi.appendChild(make('span', {}, page._lastSeen.toLocaleString()));
pageList.appendChild(pgLi);
});

View File

@@ -229,12 +229,13 @@
span.user_known::before { background-image: url('img/user.svg') }
/* platform icons */
span.platform_macos::before, dd.platform_macos::before { background-image: url('img/apple.svg') }
span.platform_win10::before, dd.platform_win10::before { background-image: url('img/win11.svg') }
span.platform_macos::before, dd.platform_macos::before { background-image: url('img/apple.svg') }
span.platform_linux::before, dd.platform_linux::before { background-image: url('img/linux.svg') }
span.platform_ios::before, dd.platform_ios::before { background-image: url('img/ios.svg') }
span.platform_android::before, dd.platform_android::before { background-image: url('img/android.svg') }
span.platform_winold::before, dd.platform_winold::before { background-image: url('img/winold.png') }
span.platform_macosold::before, dd.platform_macosold::before { background-image: url('img/macos.svg') }
span.platform_tizen::before, dd.platform_tizen::before { background-image: url('img/tizen.png') }
span.platform_hmos::before, dd.platform_hmos::before { background-image: url('img/hmos.svg') }
span.platform_chromium::before, dd.platform_chromium::before { background-image: url('img/chromium.svg') }
@@ -253,7 +254,8 @@
span.client_samsung::before, dd.client_samsung::before { background-image: url('img/samsung.svg') }
span.client_uc::before, dd.client_uc::before { background-image: url('img/uc.svg') }
span.client_huawei::before, dd.client_huawei::before { background-image: url('img/huawei.png') }
span.client_vivaldi::before, dd.client_vivaldi::before { background-image: url('img/vivaldi.png') }
span.client_vivaldi::before, dd.client_vivaldi::before { background-image: url('img/vivaldi.svg') }
span.client_aol::before, dd.client_aol::before { background-image: url('img/aol.png') }
/* ip address type */
span.ip6::before, dd.ip6::before { background-image: url('img/ip6.svg') }