From 80ca8b5419c3dccceebdef3f68442f173c7a8199 Mon Sep 17 00:00:00 2001 From: sn0w <105451515+sn0w12@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:16:26 +0100 Subject: [PATCH] Update api --- __init__.py | 78 ++++++++++++++++++--- web/js/booruTagApi.js | 114 +++++++++++++++++++++---------- web/js/favorites/contextMenu.js | 64 +++++++++-------- web/js/textHighlight.js | 4 ++ web/js/textbox/tooltipManager.js | 42 +++++++++++- 5 files changed, 228 insertions(+), 74 deletions(-) diff --git a/__init__.py b/__init__.py index c5a08c9..38978e1 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,8 @@ import json import os +import re from pathlib import Path +from urllib.parse import quote import folder_paths from server import PromptServer from aiohttp import web @@ -138,6 +140,8 @@ async def fetch_wiki_page(request): """ Proxy endpoint to fetch Danbooru wiki pages. This avoids CSP violations by making the request server-side. + Uses the official JSON API instead of scraping HTML, which is + blocked by Cloudflare bot protection (HTTP 403) for Python clients. """ tag = request.match_info["tag"] if not tag: @@ -146,34 +150,88 @@ async def fetch_wiki_page(request): # Clean the tag similar to frontend logic tag = tag.strip() - url = f"https://danbooru.donmai.us/wiki_pages/{tag}" + api_url = "https://danbooru.donmai.us/wiki_pages.json" + headers = { + # Danbooru requires API consumers to identify themselves. + "User-Agent": "ComfyUI-Syntax-Highlighting/1.0", + "Accept": "application/json", + } try: - async with aiohttp.ClientSession() as session: - async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as response: + async with aiohttp.ClientSession(headers=headers) as session: + async with session.get(api_url, params={"search[title]": tag}, + timeout=aiohttp.ClientTimeout(total=10)) as response: if response.status != 200: return web.json_response({ "success": False, "error": { "status": response.status, "message": f"HTTP error! status: {response.status}", - "url": url, + "url": f"{api_url}?search[title]={quote(tag)}", } - }, status=response.status) - - html_content = await response.text() + }) + pages = await response.json() + page = _find_wiki_page(pages, tag) + if page is None: + return web.json_response({ + "success": False, + "error": { + "status": 404, + "message": "Wiki page not found", + "url": f"{api_url}?search[title]={quote(tag)}", + } + }) + body = page.get("body") or "" return web.json_response({ "success": True, - "data": html_content + "data": { + "title": page.get("title"), + "body": body, + "description": _extract_wiki_description(body), + } }) except aiohttp.ClientError as e: - return web.json_response({ "success": False, "error": { "message": str(e), "url": url, } + return web.json_response({ "success": False, "error": { "message": str(e), "url": api_url, } }, status=500) except Exception as e: return web.json_response({ "success": False, "error": { "message": f"Unexpected error: {str(e)}", - "url": url, + "url": api_url, } }, status=500) + + +def _find_wiki_page(pages, tag): + """Prefer the exact title match, fall back to the first result.""" + if not pages: + return None + lowered = tag.lower() + for page in pages: + if isinstance(page, dict) and str(page.get("title", "")).lower() == lowered: + return page + return pages[0] if isinstance(pages[0], dict) else None + + +def _extract_wiki_description(body): + """Extract the first paragraph of a Danbooru DText wiki body.""" + if not body: + return None + text = body.replace("\r\n", "\n").strip() + if not text: + return None + paragraphs = [p.strip() for p in text.split("\n\n")] + first = next((p for p in paragraphs if p), "") + if not first: + return None + # Unwrap DText wiki links: [[target|label]] -> label, [[target]] -> target + first = re.sub(r"\[\[([^|\]]+)\|([^\]]+)\]\]", r"\2", first) + first = re.sub(r"\[\[([^\]]+)\]\]", r"\1", first) + lines = [] + for line in first.split("\n"): + line = re.sub(r"^(h[1-6]\.\s*|[*#\s]+)", "", line).strip() + if line: + lines.append(line) + description = " ".join(lines).strip() + return description or None diff --git a/web/js/booruTagApi.js b/web/js/booruTagApi.js index 9983c9b..a284691 100644 --- a/web/js/booruTagApi.js +++ b/web/js/booruTagApi.js @@ -113,6 +113,71 @@ export class BooruApi { return element; } + extractDescriptionFromDText(body) { + if (!body) return null; + const text = body.replaceAll("\r\n", "\n").trim(); + if (!text) return null; + const paragraphs = text.split("\n\n").map((p) => p.trim()); + const first = paragraphs.find((p) => p) ?? ""; + if (!first) return null; + // Unwrap DText wiki links: [[target|label]] -> label, [[target]] -> target + const unwrapped = first + .replace(/\[\[([^|\]]+)\|([^\]]+)\]\]/g, "$2") + .replace(/\[\[([^\]]+)\]\]/g, "$1"); + const lines = unwrapped + .split("\n") + .map((line) => line.replace(/^(h[1-6]\.\s*|[*#\s]+)/, "").trim()) + .filter((line) => line); + return lines.join(" ").trim() || null; + } + + extractDescriptionFromHtml(html) { + const body = this.getElementById(html, "wiki-page-body"); + return body?.querySelector("p")?.textContent || null; + } + + async fetchDescriptionDirect(cleanedTag) { + // Direct browser request: the browser TLS fingerprint passes + // Cloudflare protection that blocks server-side Python clients. + const url = `${this.baseUrl}/wiki_pages.json?search[title]=${encodeURIComponent(cleanedTag)}`; + const response = await this.#fetch(url); + if (!response.success || !Array.isArray(response.data) || response.data.length === 0) { + return null; + } + const page = + response.data.find((p) => p?.title?.toLowerCase() === cleanedTag.toLowerCase()) ?? + response.data[0]; + return this.extractDescriptionFromDText(page?.body); + } + + async fetchDescriptionViaProxy(cleanedTag) { + const url = `${this.proxyUrl}/${encodeURIComponent(cleanedTag)}`; + const response = await this.#fetch(url, { + responseType: "html", + useProxy: true, + }); + if (!response.success) { + return null; + } + const data = response.data; + if (typeof data === "string") { + // Legacy proxy response returning raw HTML. + return this.extractDescriptionFromHtml(data); + } + if (data && typeof data === "object") { + return data.description ?? this.extractDescriptionFromDText(data.body); + } + return null; + } + + async resolveDescription(cleanedTag) { + // Prefer the direct request, fall back to the server proxy + // (e.g. when CSP blocks cross-origin requests from the browser). + const direct = await this.fetchDescriptionDirect(cleanedTag); + if (direct !== null) return direct; + return this.fetchDescriptionViaProxy(cleanedTag); + } + async getTagDescription(tag) { const cleanedTag = this.cleanTag(tag); @@ -130,31 +195,19 @@ export class BooruApi { // Revalidate in background (async () => { try { - const url = `${this.proxyUrl}/${cleanedTag}`; - const response = await this.#fetch(url, { - responseType: "html", - useProxy: true, - }); - if (response.success) { - const body = this.getElementById( - response.data, - "wiki-page-body" - ); - const newDescription = - body?.querySelector("p")?.textContent || null; - // Update cache with fresh data - this.cache[cleanedTag] = { - description: newDescription, - timestamp: Date.now(), - }; - this.saveCache(); - // Trim cache if it exceeds max size - if ( - Object.keys(this.cache).length > - this.MAX_CACHE_SIZE - ) { - this.trimCache(); - } + const newDescription = await this.resolveDescription(cleanedTag); + // Update cache with fresh data + this.cache[cleanedTag] = { + description: newDescription, + timestamp: Date.now(), + }; + this.saveCache(); + // Trim cache if it exceeds max size + if ( + Object.keys(this.cache).length > + this.MAX_CACHE_SIZE + ) { + this.trimCache(); } } catch (e) { console.warn("Background revalidation failed", e); @@ -165,16 +218,7 @@ export class BooruApi { } // No cache: fetch fresh - const url = `${this.proxyUrl}/${cleanedTag}`; - const response = await this.#fetch(url, { - responseType: "html", - useProxy: true, - }); - if (!response.success) { - return null; - } - const body = this.getElementById(response.data, "wiki-page-body"); - const description = body?.querySelector("p")?.textContent || null; + const description = await this.resolveDescription(cleanedTag); // Cache the result this.cache[cleanedTag] = { description, timestamp: Date.now() }; diff --git a/web/js/favorites/contextMenu.js b/web/js/favorites/contextMenu.js index 14367ee..8f44cee 100644 --- a/web/js/favorites/contextMenu.js +++ b/web/js/favorites/contextMenu.js @@ -199,34 +199,42 @@ async function observeContextMenu(existingList) { const favoriteColor = settings["Combo Highlight Color"]; const brighterFavoriteColor = brightenColor(favoriteColor); - const style = document.createElement("style"); - style.textContent = ` - .preview-image { - position: absolute; - z-index: 2000; - border: 1px solid #ccc; - border-radius: 4px; - box-shadow: 0 2px 8px rgba(0,0,0,0.2); - pointer-events: none; - opacity: 0; - transition: opacity 0.2s ease-in-out; - } - .preview-image.fade-in { - opacity: 1; - } - .preview-image.fade-out { - opacity: 0; - } - .litegraph.litecontextmenu.dark .litemenu-entry.litemenu-entry-custom { - background-color: ${favoriteColor} !important; - display: flex; - align-items: center; - } - .litemenu-entry:hover:not(.disabled):not(.separator).litemenu-entry-custom:hover { - background-color: ${brighterFavoriteColor} !important; - } - `; - document.head.appendChild(style); + const style = document.getElementById("comfy-ui-styles") || (() => { + const s = document.createElement("style"); + s.id = "comfy-ui-styles"; + document.head.appendChild(s); + return s; + })(); + + if (!style.dataset.contextMenuStylesAdded) { + style.textContent += ` + .preview-image { + position: absolute; + z-index: 2000; + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease-in-out; + } + .preview-image.fade-in { + opacity: 1; + } + .preview-image.fade-out { + opacity: 0; + } + .litegraph.litecontextmenu.dark .litemenu-entry.litemenu-entry-custom { + background-color: ${favoriteColor} !important; + display: flex; + align-items: center; + } + .litemenu-entry:hover:not(.disabled):not(.separator).litemenu-entry-custom:hover { + background-color: ${brighterFavoriteColor} !important; + } + `; + style.dataset.contextMenuStylesAdded = "true"; + } let images; try { diff --git a/web/js/textHighlight.js b/web/js/textHighlight.js index 6b9c392..3fafd5f 100644 --- a/web/js/textHighlight.js +++ b/web/js/textHighlight.js @@ -4,6 +4,10 @@ import { setOverlayStyle } from "./textbox/styleUtils.js"; import { api } from "../../../scripts/api.js"; import { settingsHelper } from "./settings.js"; +const sharedStyle = document.createElement("style"); +sharedStyle.id = "syntax-highlighting-custom-ui-styles"; +document.head.appendChild(sharedStyle); + config.init(); api.addEventListener("update_text_highlight", async () => { diff --git a/web/js/textbox/tooltipManager.js b/web/js/textbox/tooltipManager.js index 868dcbb..51ea43a 100644 --- a/web/js/textbox/tooltipManager.js +++ b/web/js/textbox/tooltipManager.js @@ -4,6 +4,9 @@ import { settingsHelper } from "../settings.js"; const booruApi = new BooruApi(); let currentTooltipRequest = null; +const TAG_WIKI_BASE = "https://danbooru.donmai.us/wiki_pages/"; +const resolvedTags = new Set(); + export async function showTagTooltip(element, tag) { const requestId = Symbol("tooltip-request"); currentTooltipRequest = requestId; @@ -43,7 +46,12 @@ export async function showTagTooltip(element, tag) { } }, 100); - const description = (await booruApi.getTagDescription(tag)) ?? "No description available."; + const rawDescription = await booruApi.getTagDescription(tag); + const description = rawDescription ?? "No description available."; + + if (rawDescription !== null) { + resolvedTags.add(booruApi.cleanTag(tag)); + } if (currentTooltipRequest !== requestId) { clearTimeout(loadingTimeout); @@ -108,6 +116,21 @@ export async function addTooltips(textarea) { }); textarea.addEventListener("mousemove", (e) => { + if (e.ctrlKey || e.metaKey) { + textarea.style.pointerEvents = "none"; + const el = document.elementFromPoint(e.clientX, e.clientY); + textarea.style.pointerEvents = "auto"; + const tagSpan = el?.closest(".tag-span"); + if (tagSpan) { + const tag = booruApi.cleanTag(tagSpan.dataset.tag); + textarea.style.cursor = resolvedTags.has(tag) ? "pointer" : ""; + } else { + textarea.style.cursor = ""; + } + } else { + textarea.style.cursor = ""; + } + if (isTyping) return; if (tooltipTimeout) { @@ -135,10 +158,27 @@ export async function addTooltips(textarea) { }); textarea.addEventListener("mouseout", () => { + textarea.style.cursor = ""; if (tooltipTimeout) { clearTimeout(tooltipTimeout); } hideTagTooltip(); currentTooltipTag = null; }); + + textarea.addEventListener("click", (e) => { + if (!e.ctrlKey && !e.metaKey) return; + + textarea.style.pointerEvents = "none"; + const elementBelow = document.elementFromPoint(e.clientX, e.clientY); + textarea.style.pointerEvents = "auto"; + + const tagSpan = elementBelow?.closest(".tag-span"); + if (tagSpan) { + const tag = booruApi.cleanTag(tagSpan.dataset.tag); + if (!resolvedTags.has(tag)) return; + const newWindow = window.open(TAG_WIKI_BASE + encodeURIComponent(tag), "_blank"); + if (newWindow) newWindow.focus(); + } + }); }