// ==UserScript==
// @name dA_showAIOnThumb
// @namespace http://phi.pf-control.de
// @version 2024-05-10_2
// @description Display on thumbnail that art was generated using AI!
// @author Dediggefedde
// @match *://*.deviantart.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=deviantart.com
// @grant GM_addStyle
// @grant GM.setValue
// @grant GM.getValue
// @license MIT; http://opensource.org/licenses/MIT
// @sandbox DOM
// @noframes
// ==/UserScript==
(function() {
'use strict';
let settings = { //default setting values
onlyOnHover: false, //bool, true: check all thumbs for AI automatically; false: check only on hover
AITags: ["ai","aiart","dreamup","ai_generated","stable_diffusion"], //tags that will mark an art as AI in addition to dA's own "created with AI tools" marker
hideAIThumbs: false, //remove AI thumbs instead of marking them
// moveOtherThumbs:false, //not implemented
autoIgnore:false,
matureHandling:1 //{0:ignore, 1:blurr, 2:remove}
};
let bounceInterval =100; //int [ms], avoid multiple activation at once, minimum time before activating script again
//helper variables
let fetchedIDs={};
let fetchedMatures={};
let debounceTimeout = null; // Debounce-Timer
//style for isAIgenerated!
//checked items have the attribute. The value is "1" if they are AI generated, otherwise "0"
//here: AI text with white background and blue circle over the thumbnail with 70% transparency
GM_addStyle(`
[isAIGenerated="1"] {
/*thumbnail-link detected as AI*/
position: relative;
}
[isAIGenerated="1"]::after {
/* AI tag image above thumbnail*/
content: "AI" !important;
position: relative !important;
left: 50% !important;
top: -95% !important;
padding: 5px !important;
background: radial-gradient(ellipse at center, rgb(var(--g-bg-primary-rgb, 255, 255, 255)) 0%, rgb(var(--g-bg-primary-rgb, 255, 255, 255)) 60%, rgb(var(--green4-rgb, 0, 230, 150)) 65%, rgb(var(--green4-rgb, 0, 230, 150)) 70%, rgba(0, 0, 0, 0) 75%) !important;
color: var(--g-typography-primary, black) !important;
width: 15px !important;
height: 15px !important;
line-height: 15px !important;
display: block !important;
filter: opacity(70%) !important;
transform: translateX(-50%) !important;
}
[isMature="2"] {
visibility:hidden;
}
[isMature="1"]>* {
filter: blur(10px);
}
[isMature="1"]:hover>* {
filter: none;
}
[isMature="1"]::after {
content: "M" !important;
position: relative !important;
left: 50% !important;
top: -95% !important;
padding: 5px !important;
width: 15px !important;
height: 15px !important;
line-height: 15px !important;
display: block !important;
filter: opacity(70%) !important;
transform: translateX(-50%) !important;
background: radial-gradient(ellipse at center, rgb(var(--g-bg-primary-rgb, 255, 255, 255)) 0%, rgb(var(--g-bg-primary-rgb, 255, 255, 255)) 60%, red 65%, red 70%, rgba(0, 0, 0, 0) 75%) !important;
color: var(--g-typography-primary, black) !important;
}
[isMature="1"]:hover::after{
content: none!important;
}
.dA_saiot_oldwatch [isAIGenerated="1"] {
/*thumbnail link in /notifications/watch*/
position: absolute;
}
.dA_saiot_oldwatch [isAIGenerated="1"]::after {
/* AI tag in /notifications/watch*/
top: 5% !important;
visibility: visible;
}
#dA_saiot_notify p {
/* Notification text*/
font-weight: bold;
text-align: center;
margin: 0;
color: var(--g-typography-secondary, black);
}
#dA_saiot_notify {
/* Notification Container*/
position: fixed;
width: 400px;
display: block;
top: 0%;
background-color: var(--g-bg-tertiary, white);
padding: 10px;
border-radius: 0 10px 10px 0;
border: 1px solid var(--g-divider1, black);
box-shadow: 1px 1px 2px var(--g-bg-primary, black);
transition: left;
transition-duration: 0.5s;
transform: translateY(100%) translateY(10px);
color: var(--g-typography-primary, black);
}
div.settings_form label {
cursor: pointer;
}
.da_saiot_radiogroup {
display: flex;
gap: 10px; /* Abstand zwischen den Radio-Buttons */
align-items: center; /* Vertikale Zentrierung */
}
.da_saiot_radiogroup input {
margin-right: 5px; /* Abstand zwischen Radio-Button und Label */
}
.da_saiot_radiogroup label {
margin-right: 20px; /* Abstand zwischen den Labeln */
}
/*Settings form headings*/
`);
let msgbox,viewtimer;
let thumbs;
function notify(text){
msgbox.innerHTML="<p>dA_showAiOnThumb</p>"+text;
msgbox.style.left="0px";
if(viewtimer!=null)clearTimeout(viewtimer);
viewtimer=setTimeout(()=>{msgbox.style.left="-450px";},2000);
}
//request deviation data. deviation id, username and type ("art") is in the url.
//include_session=false necessary
function requestDevData(devID, username,type){
let token=document.querySelector("input[name=validate_token]").value;
return fetch(`https://www.deviantart.com/_puppy/dadeviation/init?deviationid=${devID}&username=${username}&type=${type}&include_session=false&csrf_token=${token}`, {
method: "GET",
headers: {
"accept": 'application/json, text/plain, */*',
"content-type": 'application/json;charset=UTF-8'
},
credentials: 'include' // Cookies!
}).then(async (response) => {
if (!response.ok) {
throw response; // HTTP-Statuscode
}
try{
const result = await response.json(); // JSON parsen
if (result.deviation === undefined || result.error !== undefined) {
throw result;
}
return result; // Erfolgreiche Antwort
}catch(ex){
console.log("dA_showAiOnThumb error: parsing resonce",response);
throw response;
}
});
}
//uses the da_ignore script (v2.2) to add AI making usernames automatically to an ignore list
function autoignoreNam(el){
if(!settings.autoIgnore)return;
let nam=el.parentNode.querySelector("[data-username]").dataset.username;
let ignoreEl=document.createElement("div");
ignoreEl.classList.add("dA_ignore_externalAddName");
ignoreEl.innerHTML=nam;
document.body.appendChild(ignoreEl);
}
//takes a thumbnail link element, extracts information, triggers request and adds isAIGenerated attribute
function checkAIGenerated(el){
if(el.hasAttribute("isAIGenerated"))return; //skip for items already checked
let handled=false;
let url=el.href;
let dats=/deviantart.com\/(.*?)\/(.*?)\/.*?-(\d+)$/gi.exec(url); //[match, artis, type, id] extracted from URL
if(!dats)return;
if(fetchedIDs[dats[3]]!==undefined){ //cached results for dev ID
el.setAttribute("isAIGenerated",fetchedIDs[dats[3]]);
handled=true;
}
if( settings.matureHandling>0 && fetchedMatures[dats[3]]!==undefined){
el.setAttribute("isMature",fetchedMatures[dats[3]]);
handled=true;
}
if(handled>0)return;
requestDevData(dats[3],dats[1],dats[2]).then((res)=>{ //request of extented data from PUPPY-API
try{ //responce might be successfull but have other object members
if(res.deviation.isAiGenerated){ //extract and add information
el.setAttribute("isAIGenerated","1"); //set element information
fetchedIDs[dats[3]]="1"; //cache result for deviation id.
autoignoreNam(el);
}else{
el.setAttribute("isAIGenerated","0");
fetchedIDs[dats[3]]="0";
}
if(res.deviation.isMature){
el.setAttribute("isMature",settings.matureHandling);
fetchedMatures[dats[3]]=settings.matureHandling;
}else{
fetchedMatures[dats[3]]="0";
}
if(res.deviation.extended.tags!=null){
res.deviation.extended.tags.forEach(tg=>{
if(settings.AITags.includes(tg.name)){
el.setAttribute("isAIGenerated","1"); //set element information
fetchedIDs[dats[3]]="1"; //cache result for deviation id.
autoignoreNam(el);
// moveAIImgs(el);
}});
}
}catch(ex){
console.log("dA_showAIOnThumb Error 2",ex,res); //error code 2, exception and return from server
}
})
.catch(err=>{
console.log("dA_showAIOnThumb Error 3",err); //error code 3, error code from promise call
});
}
function init(){ //called on DOM change
if(window.location.href.indexOf("/notifications/watch") > -1) document.body.classList.add("dA_saiot_oldwatch");
else document.body.classList.remove("dA_saiot_oldwatch");
if (location.href.indexOf('https://www.deviantart.com/settings') == 0 && document.getElementById("dA_showAiOnThumb_Options")==null) {
if(!document.querySelector("#dA_saiot_notify")){
msgbox=document.createElement("div");
msgbox.id="dA_saiot_notify";
msgbox.style.left="-450px";
document.body.append(msgbox);
}
let menuPoint = document.createElement("li");
menuPoint.innerHTML='<a href="#">AI Thumbnail</a>';
menuPoint.id="dA_showAiOnThumb_Options";
document.getElementById("settings_public").parentNode.after(menuPoint);
menuPoint.firstChild.addEventListener("click",(ev)=>{
document.querySelector("a.active").classList.remove("active");
ev.target.classList.add("active");
document.querySelector('div.settings_form').innerHTML=`
<div class="fooview ch">
<div class="fooview-inner">
<h3>dA_showAIOnThumb Settings</h3>
<div class="altaltview altaltview-wider">
<div class="row">
<input ${ settings.onlyOnHover ? 'checked="checked"' : '' } type="checkbox" id="da_saiot_checkhover" class="icheckbox">
<label for="da_saiot_checkhover" class="l">Mark AI thumbs only on hover</label>
<br><small>Check and mark AI images only when moving the cursor over the Thumbnail. Can improve performance on slower computers/connections. Otherwise all thumbnail are checked and marked when the appear.</small>
</div>
<div class="row">
<input value='${ settings.AITags?settings.AITags.join(","):'' }' type="text" id="da_saiot_AITags" class="itext_uplifted" />
<label for="da_saiot_AITags" class="l">Tags that mark deviations as AI</label>
<br><small>Comma-separated. Deviations with tags in this list will be marked as AI-generated. If Deviantart marks a submission as AI-generated/assisted on its own, it will be marked in any case.</small>
</div>
<div class="row">
<input ${ settings.hideAIThumbs ? 'checked="checked"' : '' } type="checkbox" id="da_saiot_removeAI" class="icheckbox">
<label for="da_saiot_removeAI" class="l">Remove AI thumbs instead of marking them</label>
<br><small>Instead of marking AI thumbnails, this will remove them and leave an empty space in place.</small>
</div>
<div class="row">
<label class="l">Handle Mature Submissions</label>
<div class='da_saiot_radiogroup'>
<input type="radio" id="mature_ignore" name="matureSetting" value="0"
${settings.matureHandling === 0 ? 'checked="checked"' : ''}>
<label for="mature_ignore">Ignore</label><br>
<input type="radio" id="mature_blur" name="matureSetting" value="1"
${settings.matureHandling === 1 ? 'checked="checked"' : ''}>
<label for="mature_blur">Blur</label><br>
<input type="radio" id="mature_hide" name="matureSetting" value="2"
${settings.matureHandling === 2 ? 'checked="checked"' : ''}>
<label for="mature_hide">Hide</label>
</div>
<small>Choose how to handle mature submissions: Ignore, blur them, or hide them completely.</small>
</div>
<div class="row">
<input ${ settings.autoIgnore ? 'checked="checked"' : '' } type="checkbox" id="da_saiot_autoIgnore" class="icheckbox">
<label for="da_saiot_removeAI" class="l">Automatically ignore users that post AI images</label>
<br><small>This requires the userscript <a href='https://www.deviantart.com/dediggefedde/art/dA-Ignore-455554874'>dA_ignore</a>! It will add users that have posted AI art automatically to the ignore-list of dA_ignore.</small>
</div>
</div>
<div class=" buttons ch hh " id="submit">
<div style="text-align:right" class="rr">
<a class="smbutton smbutton-green" href="javascript:void(0)"><span id="da_saiot_saveSettings">Save</span></a>
</div>
</div>
</div>
</div>
`;
document.getElementById('da_saiot_saveSettings').addEventListener("click",(ev)=> {
settings.onlyOnHover = document.getElementById("da_saiot_checkhover").checked;
settings.hideAIThumbs = document.getElementById("da_saiot_removeAI").checked;
settings.autoIgnore = document.getElementById("da_saiot_autoIgnore").checked;
settings.AITags = document.getElementById("da_saiot_AITags").value.split(',').map((el)=>{return el.trim();});
settings.matureHandling = parseInt(document.querySelector('input[name="matureSetting"]:checked')?.value??1);
setTimeout(() => {
GM.setValue('settings',JSON.stringify(settings));
notify("Settings saved!");
}, 0);
},false);
},false);
}
//check all thumbs which were not already checked
thumbs=[...document.querySelectorAll(`[data-testid="thumb"]:not([da_showaionthumb])`)];
thumbs.forEach(el=>{
let thmb=el.querySelector("a:not([data-username])");
if(!thmb && el.tagName=="A")thmb=el;
if(!thmb && el.parentNode.tagName=="A")thmb=el.parentNode;
if(!thmb)thmb=el.parentNode.querySelector("a");
if(!thmb)thmb=el.parentNode.parentNode.querySelector("a");
if(!thmb)thmb=el.parentNode.parentNode.parentNode.querySelector("a");
if(!thmb)return;
if(!thmb.style.height)thmb.style.height=thmb.offsetHeight+"px"; //workaround for using "position:relative;top:-95%;"
el.setAttribute("da_showaionthumb",""); //mark thumb as checked
if(!settings.onlyOnHover){ //check all immediatelly
checkAIGenerated(thmb); //function will cancel if already checked
}else{ //check on mouseover
thmb.addEventListener("mouseenter",(ev=>{
checkAIGenerated(ev.target);
}),false); //no bubbling
}
});
}
//delayed debounce to avoid calling it multiple times at once
function debouncer(){
if (debounceTimeout) { //within bounce interval
clearTimeout(debounceTimeout);
}
debounceTimeout = setTimeout(() => {
init();
debounceTimeout = null;
}, bounceInterval);
}
//loading settings
GM.getValue("settings").then((res)=>{
if(res==null)return;
try{
let savedSettings=JSON.parse(res);
Object.entries(savedSettings).forEach(([key,val])=>{settings[key]=val});
}catch(ex){
console.log("dA_showAiOnThumb Error: loading settings",ex);
}
if(settings.hideAIThumbs){
GM_addStyle("div:has(>[isAIGenerated='1']){display:none!important;}");
}
}).finally(()=>{
const observer = new MutationObserver(debouncer);
observer.observe(document.body,{ childList: true, subtree: true });
debouncer();
});
})();