// ==UserScript==
// @name ShikiSearch+
// @icon https://www.google.com/s2/favicons?domain=shikimori.me
// @namespace https://shikimori.one
// @version 1.7
// @description Добавляет больше ссылок в раздел "На других сайтах"
// @author LifeH
// @match *://shikimori.org/*
// @match *://shikimori.one/*
// @match *://shikimori.me/*
// @grant none
// @license MIT
// @require https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.6/Sortable.min.js
// ==/UserScript==
(function() {
'use strict';
const allowedPaths = ["/ranobe/", "/animes/", "/mangas/"];
let isEditing = false;
let editingIndex = null;
const defaultLinks = [
{
title: "Anime-joy",
icon: "anime-joy.online",
link: "https://anime-joy.online/index.php?story={search}&do=search&subaction=search",
searchMethod: "title",
group: "animes",
enabled: true,
},
{
title: "Anilibria",
icon: "anilibria.tv",
link: "https://www.anilibria.tv/release/{search}.html",
searchMethod: "slug",
group: "animes",
enabled: true,
},
{
title: "Anilibria Top",
icon: "anilibria.top",
link: "https://anilibria.top/anime/releases/release/{id}",
searchMethod: "anilibriaApi",
group: "animes",
enabled: true,
},
{
title: "Animego",
icon: "animego.me",
link: "https://animego.me/search/all?q={search}",
searchMethod: "title",
group: "animes",
enabled: true,
},
{
title: "Anilib",
icon: "anilib.me",
link: "https://anilib.me/ru/catalog?q={search}",
searchMethod: "title",
group: "animes",
enabled: true,
},
{
title: "Jut.su",
icon: "jut.su",
link: "https://jut.su/search/?searchid=1893616&text={search}&web=0#",
searchMethod: "title",
group: "animes",
enabled: true,
},
{
title: "rutracker",
icon: "rutracker.org",
link: "https://rutracker.org/forum/tracker.php?nm={search}",
searchMethod: "title",
group: "animes",
enabled: true,
},
{
title: "Yummy-anime",
icon: "yummy-anime.ru",
link: "https://yummy-anime.ru/search?word={search}",
searchMethod: "title",
group: "animes",
enabled: false,
},
{
title: "Animevost",
icon: "animevost.org",
link: "https://animevost.org/index.php?do=search&subaction=search&search_start=0&full_search=0&result_from=1&story={search}",
searchMethod: "title",
group: "animes",
enabled: false,
},
{
title: "Crunchyroll",
icon: "crunchyroll.com",
link: "https://www.crunchyroll.com/search?q={search}",
searchMethod: "originalTitle",
group: "animes",
enabled: false,
},
{
title: "Amedia",
icon: "amedia.lol",
link: "https://amedia.lol/index.php?do=search&subaction=search&from_page=0&story={search}",
searchMethod: "title",
group: "animes",
enabled: false,
},
{
title: "Rezka",
icon: "rezka.ag",
link: "https://rezka.ag/search/?do=search&subaction=search&q={search}",
searchMethod: "title",
group: "animes",
enabled: false,
},
{
title: "Anime1",
icon: "anime1.best",
link: "https://anime1.best/index.php?do=search&subaction=search&search_start=0&full_search=0&result_from=1&story={search}",
searchMethod: "title",
group: "animes",
enabled: false,
}
];
function getGroup() {
const path = window.location.pathname;
return allowedPaths.find((p) => path.startsWith(p))?.replace(/\//g, "");
}
function getTitle() {
let titleElement = document.querySelector(".head h1");
return titleElement
? titleElement.childNodes[0].textContent.trim()
: null;
}
function getId() {
const pathParts = window.location.pathname.split("/");
const idPart = pathParts[2] || "";
const match = idPart.match(/^[a-z]*(\d+)/);
return match ? match[1] : null;
}
function getOriginalTitle() {
const titleElement = document.querySelector(".head h1");
if (!titleElement) return null;
const separator = titleElement.querySelector(".b-separator.inline");
if (!separator) return null;
const originalTitle = separator.nextSibling?.textContent?.trim();
return originalTitle || null;
}
function getSlug() {
let path = window.location.pathname;
let match = path.match(/\/(animes|mangas|ranobe)\/z?(\d+)-(.+)/);
return match ? match[3] : null;
}
function getLinks() {
let storedLinks = JSON.parse(localStorage.getItem("userLinks"));
if (!storedLinks) {
storedLinks = defaultLinks;
saveLinks(storedLinks);
return storedLinks;
}
let deletedLinks = JSON.parse(localStorage.getItem("deletedLinks")) || [];
let updated = false;
defaultLinks.forEach((defaultLink) => {
if (deletedLinks.includes(defaultLink.title)) return;
if (!storedLinks.some(link => link.title === defaultLink.title)) {
storedLinks.push(defaultLink);
updated = true;
}
});
if (updated) {
saveLinks(storedLinks);
}
return storedLinks;
}
function saveLinks(links) {
localStorage.setItem("userLinks", JSON.stringify(links));
}
function linkBuilder({ icon, link, searchMethod, group: linkGroup, title, enabled }) {
if (!enabled) return;
const group = getGroup();
if (linkGroup !== group) return;
let parentBlock = document.querySelector(".subheadline.m8")?.parentElement;
if (!parentBlock) return;
let searchParam;
if (searchMethod === "slug") {
searchParam = getSlug();
} else if (searchMethod === "id") {
searchParam = getId();
} else if (searchMethod === "originalTitle") {
searchParam = getOriginalTitle();
} else {
searchParam = getTitle();
}
if (!searchParam) return;
let url = link.replace("{search}", encodeURIComponent(searchParam));
let linkContainer = document.createElement('div');
linkContainer.className = 'b-external_link b-menu-line';
let anchor = document.createElement('a');
anchor.className = 'b-link';
anchor.href = url;
anchor.textContent = title;
anchor.target = '_blank';
if (icon) {
let img = document.createElement('img');
img.src = `https://www.google.com/s2/favicons?domain=${icon}`;
img.style.width = '19px';
img.style.height = '19px';
img.style.marginRight = '5px';
anchor.prepend(img);
}
linkContainer.appendChild(anchor);
parentBlock.appendChild(linkContainer);
if (searchMethod === "anilibriaApi") {
const title = getTitle();
const apiUrl = `https://anilibria.top/api/v1/app/search/releases?query=${encodeURIComponent(title)}`;
fetch(apiUrl)
.then(response => response.json())
.then(data => {
if (data && data.length > 0) {
const id = data[0].id;
const finalUrl = `https://anilibria.top/anime/releases/release/${id}`;
anchor.href = finalUrl;
}
})
.catch(error => {
console.error(error);
});
}
}
function init() {
let links = getLinks();
links.forEach(linkBuilder);
}
function GUI() {
const settingsBlock = document.querySelector('.block.edit-page.misc');
if (!settingsBlock) return;
if (document.querySelector('.shikisearch-config')) return;
let container = document.createElement('div');
container.className = 'shikisearch-config';
container.style.padding = '20px';
container.style.border = '1px solid #ccc';
container.style.marginTop = '20px';
container.style.background = '#f9f9f9';
container.style.borderRadius = '8px';
container.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
container.style.position = 'relative';
container.innerHTML = `
<h3 style="margin-bottom: 20px; text-align: center;">[ShikiSearch+] Config</h3>
<div style="display: flex; flex-direction: column; gap: 10px;">
<input type="text" id="title" placeholder="Название" style="padding: 8px; border-radius: 4px; border: 1px solid #ccc;">
<input type="text" id="icon" placeholder="Домен (например: shikimori.one)" style="padding: 8px; border-radius: 4px; border: 1px solid #ccc;">
<input type="text" id="link" placeholder="Шаблон для ссылки поиска (используйте {search})" style="padding: 8px; border-radius: 4px; border: 1px solid #ccc;">
<select id="searchMethod" style="padding: 8px; border-radius: 4px; border: 1px solid #ccc;">
<option value="title">По названию</option>
<option value="originalTitle">По названию (оригинал)</option>
<option value="slug">По Slug</option>
<option value="id">По ID</option>
<option value="anilibriaApi">anilibriaApi</option>
</select>
<select id="group" style="padding: 8px; border-radius: 4px; border: 1px solid #ccc;">
<option value="animes">Аниме</option>
<option value="mangas">Манга</option>
<option value="ranobe">Ранобэ</option>
</select>
<button id="addLink" style="padding: 10px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer;">Добавить</button>
</div>
<div id="linksList" style="margin-top: 20px; display: flex; flex-direction: column; gap: 10px;"></div>
<span class="tooltip" style="position: absolute; top: 10px; right: 10px; cursor: help;">?
<span class="tooltiptext">
<strong>Информация:</strong><br>
"По названию": Русское названия тайтла.<br>
"По Slug": Использует часть ссылки.<br>
</span>
</span>
`;
settingsBlock.appendChild(container);
let style = document.createElement('style');
style.textContent = `
.tooltip {
position: relative;
display: inline-block;
cursor: help;
font-size: 14px;
color: #555;
}
.tooltip .tooltiptext {
visibility: hidden;
width: 250px;
background-color: #555;
color: #fff;
text-align: left;
border-radius: 6px;
padding: 10px;
position: absolute;
z-index: 1000;
top: 100%;
right: 0;
opacity: 0;
transition: opacity 0.3s;
font-size: 12px;
white-space: normal;
}
.tooltip:hover .tooltiptext {
visibility: visible;
opacity: 1;
}
`;
document.head.appendChild(style);
document.getElementById('addLink').addEventListener('click', () => {
let title = document.getElementById('title').value.trim();
let icon = document.getElementById('icon').value.trim();
let link = document.getElementById('link').value.trim();
let searchMethod = document.getElementById('searchMethod').value;
let group = document.getElementById('group').value;
if (!title || !link) {
alert('Заполните название и ссылку!');
return;
}
let links = getLinks();
if (isEditing) {
links[editingIndex] = { title, icon, link, searchMethod, group, enabled: true };
isEditing = false;
editingIndex = null;
document.getElementById('addLink').textContent = 'Добавить';
} else {
links.push({ title, icon, link, searchMethod, group, enabled: true });
}
saveLinks(links);
updateLinksList();
clearForm();
});
updateLinksList();
}
function updateLinksList() {
let linksList = document.getElementById('linksList');
linksList.innerHTML = '';
let links = getLinks();
links.forEach((link, index) => {
let card = document.createElement('div');
card.style.display = 'flex';
card.style.alignItems = 'center';
card.style.justifyContent = 'space-between';
card.style.padding = '10px';
card.style.border = '1px solid #ccc';
card.style.borderRadius = '8px';
card.style.background = '#fff';
card.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
card.style.cursor = 'grab';
card.setAttribute('data-index', index);
card.setAttribute('draggable', 'true');
card.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', '');
e.target.style.opacity = '0.4';
});
card.addEventListener('dragend', (e) => {
e.target.style.opacity = '1';
});
card.innerHTML = `
<div style="display: flex; align-items: center; gap: 10px;">
<img src="https://www.google.com/s2/favicons?domain=${link.icon}" style="width: 16px; height: 16px;">
<span>${link.title} (${link.group})</span>
</div>
<div style="display: flex; align-items: center; gap: 10px;">
<label class="toggle-switch">
<input type="checkbox" ${link.enabled ? 'checked' : ''} onchange="toggleLink(${index}, this.checked)">
<span class="slider"></span>
</label>
<button onclick="editLink(${index})" style="padding: 5px 10px; background-color: #FFC107; color: white; border: none; border-radius: 4px; cursor: pointer;">Редактировать</button>
<button onclick="deleteLink(${index})" style="padding: 5px 10px; background-color: #F44336; color: white; border: none; border-radius: 4px; cursor: pointer;">Удалить</button>
</div>
`;
linksList.appendChild(card);
});
new Sortable(linksList, {
animation: 150,
onEnd: function (evt) {
let links = getLinks();
let movedItem = links.splice(evt.oldIndex, 1)[0];
links.splice(evt.newIndex, 0, movedItem);
saveLinks(links);
updateLinksList();
}
});
let style = document.createElement('style');
style.textContent = `
.toggle-switch {
position: relative;
display: inline-block;
width: 40px;
height: 20px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.4s;
border-radius: 20px;
}
.slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background-color: white;
transition: 0.4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #4CAF50;
}
input:checked + .slider:before {
transform: translateX(20px);
}
.sortable-chosen {
background-color: #f0f0f0;
}
.sortable-ghost {
opacity: 0.5;
}
`;
document.head.appendChild(style);
}
function clearForm() {
document.getElementById('title').value = '';
document.getElementById('icon').value = '';
document.getElementById('link').value = '';
document.getElementById('searchMethod').value = 'title';
document.getElementById('group').value = 'animes';
}
window.deleteLink = function(index) {
if (confirm('Вы уверены, что хотите удалить эту ссылку?')) {
let links = getLinks();
let deletedLink = links[index];
if (defaultLinks.some(link => link.title === deletedLink.title)) {
let deletedLinks = JSON.parse(localStorage.getItem("deletedLinks")) || [];
if (!deletedLinks.includes(deletedLink.title)) {
deletedLinks.push(deletedLink.title);
localStorage.setItem("deletedLinks", JSON.stringify(deletedLinks));
}
}
links.splice(index, 1);
saveLinks(links);
updateLinksList();
}
};
window.editLink = function(index) {
let links = getLinks();
let link = links[index];
document.getElementById('title').value = link.title;
document.getElementById('icon').value = link.icon;
document.getElementById('link').value = link.link;
document.getElementById('searchMethod').value = link.searchMethod;
document.getElementById('group').value = link.group;
isEditing = true;
editingIndex = index;
document.getElementById('addLink').textContent = 'Сохранить изменения';
};
window.toggleLink = function(index, enabled) {
let links = getLinks();
links[index].enabled = enabled;
saveLinks(links);
};
function ready(fn) {
document.addEventListener('page:load', fn);
document.addEventListener('turbolinks:load', fn);
if (document.readyState !== "loading") {
fn();
} else {
document.addEventListener('DOMContentLoaded', fn);
}
}
ready(init);
ready(GUI);
})();