按快捷键选择页面元素,快速发送到Gemini/ChatGPT/AI Studio/DeepSeek
// ==UserScript==
// @name 一键发送到AI(支持图文)
// @name:en Ask AI Anywhere (Support Image)
// @namespace https://blog.xlab.app/
// @more https://github.com/ttttmr/UserJS
// @version 0.9
// @description 按快捷键选择页面元素,快速发送到Gemini/ChatGPT/AI Studio/DeepSeek
// @description:en Quickly send web content (text & images) to AI (Gemini, ChatGPT, AI Studio, DeepSeek) with a shortcut
// @author tmr
// @match http://*/*
// @match https://*/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// ==/UserScript==
const CONFIG = {
SHORTCUT_TRIGGER: (e) => e.altKey && e.code === "Digit2",
PROVIDERS: {
gemini: {
name: "Gemini",
url: "https://gemini.google.com/app",
inputSelector: 'div[contenteditable="true"], textarea',
sendButtonSelector: "button.submit",
},
chatgpt: {
name: "ChatGPT",
url: "https://chatgpt.com/",
inputSelector: "#prompt-textarea",
sendButtonSelector: 'button[data-testid="send-button"]',
},
aistudio: {
name: "AI Studio",
url: "https://aistudio.google.com/prompts/new_chat",
inputSelector: "ms-autosize-textarea textarea",
sendButtonSelector: 'button[aria-label="Run"]',
},
deepseek: {
name: "DeepSeek",
url: "https://chat.deepseek.com/",
inputSelector: 'textarea[placeholder*="DeepSeek"]',
sendButtonSelector: 'div[role="button"].ds-icon-button',
},
},
GENERATE_PROMPT: (data) => {
const { title, url, selection, content, images } = data;
const zh = navigator.language.toLowerCase().startsWith("zh");
const prompts = [];
if (zh) {
prompts.push(`我正在阅读:${title}`);
} else {
prompts.push(`I'm reading: ${title}`);
}
if (content) {
if (zh) {
prompts.push("内容:");
} else {
prompts.push("Content:");
}
prompts.push("```markdown");
prompts.push(content);
prompts.push("```");
}
if (selection) {
console.log("[Ask] found selection");
if (zh) {
prompts.push(`其中${selection}如何理解?`);
} else {
prompts.push(`How to understand ${selection}?`);
}
} else if (content) {
console.log("[Ask] found content");
if (zh) {
prompts.push("使用通俗的语言总结这篇文章");
} else {
prompts.push("Summarize this article in plain language");
}
} else if (images) {
console.log("[Ask] found images");
if (zh) {
prompts.push("解释这个图片");
} else {
prompts.push("Explain this image");
}
}
return prompts.join("\n");
},
};
// Helper to wait for element using MutationObserver
function waitForElement(selector, checkFn = (el) => true) {
return new Promise((resolve) => {
const element = document.querySelector(selector);
if (element && checkFn(element)) {
return resolve(element);
}
const observer = new MutationObserver(() => {
const element = document.querySelector(selector);
if (element && checkFn(element)) {
resolve(element);
observer.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
});
}
// Helper to get valid image source, handling lazy loading and relative URLs
function getImageSrc(imgNode) {
const candidates = [imgNode.src, imgNode.getAttribute("data-src")];
for (const src of candidates) {
if (src && !src.startsWith("data:")) {
try {
return new URL(src, location.href).href;
} catch {}
}
}
return null;
}
// Helper to check if image should be included (filters out icons, avatars, etc.)
function shouldIncludeImage(imgNode) {
// Filter by keywords
const keywords = ["avatar", "icon", "logo", "profile"];
const checkStr = `${imgNode.className || ""} ${imgNode.alt || ""} ${
imgNode.id || ""
}`.toLowerCase();
if (keywords.some((k) => checkStr.includes(k))) return false;
return true;
}
// Helper to extract text (Markdown) and images from element or fragment
function extractContent(elementOrFragment) {
if (!elementOrFragment) return { text: "", images: [] };
let text = "";
const images = [];
function traverse(node) {
if (node.nodeType === Node.TEXT_NODE) {
// Escape Markdown characters in text
return node.textContent.replace(/([*_`[\]])/g, "\\$1");
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const tagName = node.tagName;
if (tagName === "SCRIPT" || tagName === "STYLE" || tagName === "NOSCRIPT")
return "";
const parts = [];
for (const child of node.childNodes) {
parts.push(traverse(child));
}
const content = parts.join("");
// Handle specific tags
switch (tagName) {
case "IMG": {
const src = getImageSrc(node);
if (src && shouldIncludeImage(node)) {
const filename = `img_${images.length + 1}`;
images.push({ url: src, filename });
return `\n![${filename}]\n`;
}
return "";
}
case "BR":
return "\n";
case "P":
case "DIV":
return `\n${content}\n`;
case "H1":
return `\n# ${content}\n`;
case "H2":
return `\n## ${content}\n`;
case "H3":
return `\n### ${content}\n`;
case "H4":
return `\n#### ${content}\n`;
case "H5":
return `\n##### ${content}\n`;
case "H6":
return `\n###### ${content}\n`;
case "STRONG":
case "B":
return `**${content}**`;
case "EM":
case "I":
return `*${content}*`;
case "A": {
const href = node.href;
if (href) {
try {
const url = new URL(href, location.href);
const isImage =
["http:", "https:"].includes(url.protocol) &&
/\.(jpeg|jpg|gif|png|webp|svg|bmp)$/i.test(url.pathname);
if (isImage) {
const filename = `img_${images.length + 1}`;
images.push({ url: href, filename });
return `\n![${filename}]\n`;
} else {
return `[${content}](${href})`;
}
} catch {}
}
}
case "CODE":
return `\`${content}\``;
case "PRE":
return `\n\`\`\`\n${content}\n\`\`\`\n`;
case "BLOCKQUOTE":
return `\n> ${content}\n`;
case "LI":
return `\n- ${content}`;
case "UL":
case "OL":
return `\n${content}\n`;
case "TR":
return `\n${content}`;
case "TD":
case "TH":
return ` ${content} |`;
default:
return content;
}
}
text = traverse(elementOrFragment);
// Clean up whitespace
text = text.replace(/\n(\s*\n)+/g, "\n").trim();
// Deduplicate images
const uniqueImages = [];
const seenUrls = new Set();
for (const img of images) {
if (!seenUrls.has(img.url)) {
seenUrls.add(img.url);
uniqueImages.push(img);
}
}
return { text, images: uniqueImages };
}
// Helper to fetch image as File object
function fetchImageAsFile(url, filename, referrer) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: url,
headers: {
Referer: referrer,
},
responseType: "blob",
onload: (response) => {
if (response.status === 200) {
const blob = response.response;
const file = new File([blob], filename, { type: blob.type });
resolve(file);
} else {
reject(new Error(`Failed to fetch image: ${response.status}`));
}
},
onerror: (err) => reject(err),
});
});
}
// DOM Selector Class
class DomSelector {
constructor() {
this.state = {
active: false,
overlay: null,
currentElement: null,
onSelect: null,
};
this.boundHandleMouseMove = this.handleMouseMove.bind(this);
this.boundHandleClick = this.handleClick.bind(this);
this.boundHandleKeydown = this.handleKeydown.bind(this);
}
injectStyles() {
if (document.getElementById("ask-ai-anywhere-selector-styles")) return;
const css = `
.ask-ai-anywhere-selector-overlay {
position: absolute;
border: 3px solid #4285f4;
background: rgba(66, 133, 244, 0.1);
pointer-events: none;
z-index: 2147483647;
transition: all 0.1s ease;
box-shadow: 0 0 0 1px rgba(66, 133, 244, 0.3);
}
.ask-ai-anywhere-selector-active {
cursor: crosshair !important;
}
.ask-ai-anywhere-selector-active * {
cursor: crosshair !important;
}
`;
const style = GM_addStyle(css);
if (style) {
style.id = "ask-ai-anywhere-selector-styles";
}
}
createOverlay() {
const overlay = document.createElement("div");
overlay.className = "ask-ai-anywhere-selector-overlay";
overlay.style.display = "none";
document.body.appendChild(overlay);
return overlay;
}
highlight(element) {
if (!this.state.overlay) return;
if (!element || element === document.documentElement) {
this.state.overlay.style.display = "none";
this.state.currentElement = null;
return;
}
const rect = element.getBoundingClientRect();
const overlay = this.state.overlay;
overlay.style.display = "block";
overlay.style.left = `${rect.left + window.scrollX}px`;
overlay.style.top = `${rect.top + window.scrollY}px`;
overlay.style.width = `${rect.width}px`;
overlay.style.height = `${rect.height}px`;
this.state.currentElement = element;
}
handleMouseMove(e) {
if (!this.state.active) return;
e.stopPropagation();
const element = document.elementFromPoint(e.clientX, e.clientY);
this.highlight(element);
}
handleClick(e) {
if (!this.state.active) return;
e.preventDefault();
e.stopPropagation();
const element = this.state.currentElement;
if (element && this.state.onSelect) {
const content = element; // Pass the whole element
this.state.onSelect(content);
this.deactivate();
}
}
handleKeydown(e) {
if (!this.state.active) return;
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
console.log("[Selector] Canceled by user");
this.deactivate();
}
}
activate(onSelect) {
if (this.state.active) return;
console.log("[Selector] Activating DOM selector");
this.injectStyles();
this.state.overlay = this.createOverlay();
this.state.active = true;
this.state.onSelect = onSelect;
document.body.classList.add("ask-ai-anywhere-selector-active");
// Add event listeners with capture to intercept all events
document.addEventListener("mousemove", this.boundHandleMouseMove, true);
document.addEventListener("click", this.boundHandleClick, true);
document.addEventListener("keydown", this.boundHandleKeydown, true);
}
deactivate() {
if (!this.state.active) return;
console.log("[Selector] Deactivating DOM selector");
document.body.classList.remove("ask-ai-anywhere-selector-active");
// Remove event listeners
document.removeEventListener("mousemove", this.boundHandleMouseMove, true);
document.removeEventListener("click", this.boundHandleClick, true);
document.removeEventListener("keydown", this.boundHandleKeydown, true);
// Clean up overlay
if (this.state.overlay) {
this.state.overlay.remove();
this.state.overlay = null;
}
this.state.active = false;
this.state.currentElement = null;
this.state.onSelect = null;
}
}
const domSelector = new DomSelector();
// Initialize Provider page to receive prompts
async function initProviderPage(providerConfig) {
console.log(`[Ask] Initializing ${providerConfig.name} page`);
// Check for prompt from URL param or storage
const urlParams = new URLSearchParams(window.location.search);
const urlPrompt = urlParams.get("q");
const prompt = urlPrompt || GM_getValue("ask_prompt");
// Check for images in storage
const storedImagesJson = GM_getValue("ask_images");
let images = [];
let referrer = "";
if (storedImagesJson) {
try {
const data = JSON.parse(storedImagesJson);
images = Array.isArray(data) ? data : data.urls;
referrer = Array.isArray(data) ? "" : data.referrer;
} catch (e) {
console.error("[Ask] Failed to parse stored images", e);
}
}
if (!prompt && (!images || images.length === 0)) return;
console.log("[Ask] Found content to process");
// Start fetching images immediately if any
const imageFetchPromise =
images && images.length > 0
? Promise.all(
images.map((img) => {
return fetchImageAsFile(img.url, img.filename, referrer).catch(
(err) => {
console.error(`[Ask] Failed to fetch image ${img.url}`, err);
return null;
}
);
})
)
: Promise.resolve([]);
if (document.readyState !== "complete") {
await new Promise((resolve) => window.addEventListener("load", resolve));
}
try {
const inputBox = await waitForElement(providerConfig.inputSelector);
console.log("[Ask] Input box found");
inputBox.focus();
// 1. Paste Images
const rawFiles = await imageFetchPromise;
const files = rawFiles.filter((f) => f !== null);
if (files.length > 0) {
console.log(
`[Ask] Waiting for window load to paste ${files.length} images...`
);
console.log(`[Ask] Window loaded, pasting images`);
const dataTransfer = new DataTransfer();
files.forEach((file) => {
console.log(
`[Ask] Adding file to DataTransfer: ${file.name} (${file.type}, ${file.size} bytes)`
);
dataTransfer.items.add(file);
});
const pasteEvent = new ClipboardEvent("paste", {
bubbles: true,
cancelable: true,
clipboardData: dataTransfer,
});
// Fallback for some browsers/environments where constructor doesn't set clipboardData correctly
if (!pasteEvent.clipboardData) {
Object.defineProperty(pasteEvent, "clipboardData", {
value: dataTransfer,
writable: false,
});
}
inputBox.dispatchEvent(pasteEvent);
}
// 2. Fill Text
if (prompt) {
console.log("[Ask] Filling text prompt");
inputBox.focus(); // Ensure focus is back on input
if (inputBox.tagName === "TEXTAREA") {
const valueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value"
).set;
valueSetter.call(inputBox, prompt);
} else {
// Safe text insertion for contenteditable
// If we just pasted images, we don't want to wipe them out with textContent = ...
// So we append a text node.
const textNode = document.createTextNode(prompt);
inputBox.appendChild(textNode);
}
inputBox.dispatchEvent(new Event("input", { bubbles: true }));
inputBox.dispatchEvent(new Event("change", { bubbles: true }));
}
// 3. Send
const btn = await waitForElement(
providerConfig.sendButtonSelector,
(btn) => {
return !btn.disabled && btn.getAttribute("aria-disabled") !== "true";
}
);
if (btn) {
console.log("[Ask] Send button ready, clicking");
btn.click();
// Cleanup
console.log("[Ask] Cleanup");
GM_deleteValue("ask_prompt");
GM_deleteValue("ask_images");
}
} catch (err) {
console.error("[Ask] Error processing content", err);
}
}
// Handle shortcut trigger
function handleShortcut(e) {
if (!CONFIG.SHORTCUT_TRIGGER(e)) return;
console.log("[Source] Shortcut triggered");
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const selection = window.getSelection();
let selectionText = "";
let selectionImages = [];
if (selection.rangeCount > 0) {
const container = document.createElement("div");
for (let i = 0; i < selection.rangeCount; i++) {
container.appendChild(selection.getRangeAt(i).cloneContents());
}
const result = extractContent(container);
selectionText = result.text;
selectionImages = result.images;
}
domSelector.activate((element) => {
const { text: content, images: elementImages } = extractContent(element);
// Combine images and deduplicate
const allImages = [...selectionImages, ...elementImages];
// Deduplicate again based on URL
const uniqueImages = [];
const seenUrls = new Set();
for (const img of allImages) {
if (!seenUrls.has(img.url)) {
seenUrls.add(img.url);
uniqueImages.push(img);
}
}
const promptText = CONFIG.GENERATE_PROMPT({
title: document.title,
url: location.href,
selection: selectionText,
content,
images: uniqueImages,
});
console.log(
"[Source] Generated prompt from element, length:",
promptText.length
);
GM_setValue("ask_prompt", promptText);
if (uniqueImages.length > 0) {
console.log(`[Source] Saving ${uniqueImages.length} images to storage`);
GM_setValue(
"ask_images",
JSON.stringify({
urls: uniqueImages,
referrer: location.href,
})
);
}
const currentProviderKey = GM_getValue("provider", "gemini");
const provider = CONFIG.PROVIDERS[currentProviderKey];
const win = window.open(provider.url, "_blank");
if (!win) {
console.log("[Source] Failed to open window");
return;
}
console.log(`[Source] ${provider.name} window opened`);
});
}
let menuIds = [];
// Register menu command to switch provider
function registerMenuCommands() {
// Unregister existing commands
for (const id of menuIds) {
GM_unregisterMenuCommand(id);
}
menuIds = [];
const currentProviderKey = GM_getValue("provider", "gemini");
Object.entries(CONFIG.PROVIDERS).forEach(([key, config]) => {
const isCurrent = currentProviderKey === key;
const title = isCurrent ? `✅ ${config.name}` : `⬜ ${config.name}`;
const id = GM_registerMenuCommand(title, () => {
GM_setValue("provider", key);
registerMenuCommands(); // Re-register to update checkmarks
});
menuIds.push(id);
});
}
(async function () {
"use strict";
// Check if we are on a provider page
const currentUrl = location.href;
for (const [key, config] of Object.entries(CONFIG.PROVIDERS)) {
if (currentUrl.startsWith(config.url)) {
await initProviderPage(config);
return; // Exit if we are on a provider page
}
}
// Otherwise, we are on a source page
registerMenuCommands();
window.addEventListener("keydown", handleShortcut, true);
})();