
Google Image Search
- 606 installs
- 339 repo stars
- Updated August 4, 2026
- glebis/claude-skills
google-image-search is a Claude Code skill that finds, scores, and downloads rights-aware images for articles, decks, or Obsidian notes using Google Custom Search API plus LLM selection for developers who need automated
About
google-image-search is a Claude Code skill from glebis/claude-skills that searches and downloads images via the Google Custom Search API with intelligent scoring and LLM-based selection. The skill supports simple keyword queries, batch processing from JSON config files, automatic config generation from search terms, and full Obsidian note enrichment with images inserted below headings. Developers reach for google-image-search when illustrating technical articles, research documents, or presentations without manually browsing stock sites, and when bulk-enriching knowledge bases stored in Obsidian. Triggers include finding images for articles, adding visuals to presentations, enriching notes, or running batch image downloads from a JSON configuration. The workflow combines API search results with LLM ranking so chosen images match article context while respecting rights-aware selection criteria stated in the skill documentation.
- Four modes: simple query, JSON batch config, auto config from term lists, and full Obsidian note enrichment under headin
- Google Custom Search API retrieval with LLM-powered selection via OpenRouter and the llm CLI
- Configurable result count, output directory, and batch workflows for many topics at once
- Designed for technical articles, presentations, and research docs—not generic stock browsing
- Credentials loaded from .env: Google API key, Search CX, and OPENROUTER_API_KEY
Google Image Search by the numbers
- 606 all-time installs (skills.sh)
- Ranked #380 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/glebis/claude-skills --skill google-image-searchAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 606 |
|---|---|
| repo stars | ★ 339 |
| Security audit | 2 / 3 scanners passed |
| Last updated | August 4, 2026 |
| Repository | glebis/claude-skills ↗ |
How do you find rights-aware images for technical docs?
Find, score, and download rights-aware images for articles, decks, or Obsidian notes using Google Custom Search plus LLM picking.
Who is it for?
Developers authoring technical articles, presentations, or Obsidian vaults who need API-driven image search with LLM-ranked selection.
Skip if: Design teams building original brand illustration systems or workflows that cannot use Google Custom Search API credentials.
When should I use this skill?
The user needs images for articles, presentations, research docs, Obsidian note enrichment, or batch image search from JSON config.
What you get
Downloaded image files, JSON batch configs, and Obsidian notes with images inserted below headings.
- downloaded image files
- JSON search configs
- enriched Obsidian notes
Files
Google Image Search Skill
Search for images using Google Custom Search API with intelligent scoring and LLM-based selection.
When to Use
- Finding images to illustrate technical articles or research
- Adding visuals to presentations
- Enriching Obsidian notes with relevant images
- Batch image search for multiple topics
- Generating image search configs from plain text lists
Requirements
- Google Custom Search API key and Search Engine ID
- OpenRouter API key (for LLM selection)
- llm CLI installed at
/opt/homebrew/bin/llm
Store credentials in .env:
Google-Custom-Search-JSON-API-KEY=your_key
Google-Custom-Search-CX=your_cx
OPENROUTER_API_KEY=your_openrouter_keyModes of Operation
1. Simple Query
Search for a single term:
python3 ~/.claude/skills/google-image-search/scripts/google_image_search.py \
--query "neural interface wearable device" \
--output-dir ./images \
--num-results 52. Batch Processing
Process multiple queries from JSON config:
python3 ~/.claude/skills/google-image-search/scripts/google_image_search.py \
--config image_queries.json \
--output-dir ./images \
--llm-select3. Generate Config from Terms
Create JSON config from a list of terms using LLM:
python3 ~/.claude/skills/google-image-search/scripts/google_image_search.py \
--generate-config \
--terms "AlterEgo wearable" "sEMG electrodes" "BCI headset" \
--output my_queries.json4. Enrich Obsidian Note
Extract visual terms from note, find images, and insert below headings:
python3 ~/.claude/skills/google-image-search/scripts/google_image_search.py \
--enrich-note ~/Brains/brain/Research/neural-interfaces.mdThis mode: 1. Detects Obsidian vault and attachments folder 2. Uses LLM to extract visual-worthy terms from note 3. Searches for images for each term 4. Downloads best images to attachments folder 5. Inserts image embeds below relevant headings 6. Creates backup before modifying note
Key Options
| Option | Description |
|---|---|
--query TEXT | Simple single query |
--config FILE | JSON config for batch |
--generate-config | Generate config from --terms |
--enrich-note FILE | Enrich Obsidian note |
--output-dir DIR | Where to save images |
--urls-only | Return URLs only, no download |
--llm-select | Use LLM to pick best image (default: on) |
--no-llm-select | Disable LLM selection |
--num-results N | Results per query (default: 5) |
--dry-run | Show what would be done |
JSON Config Format
Each entry supports:
{
"id": "unique-id",
"heading": "Display Heading",
"description": "Context for what image to find",
"query": "Google search query",
"numResults": 5,
"selectionCriteria": "What makes a good image",
"requiredTerms": ["must", "have"],
"optionalTerms": ["bonus", "terms"],
"excludeTerms": ["stock", "clipart"],
"preferredHosts": ["official-site.com"],
"selectionCount": 2
}See references/api_config_reference.md for full documentation.
Scoring System
Images are scored based on:
- Required terms: -80 if missing, +30 if all present
- Optional terms: +5 per match
- Exclude terms: -50 per match
- Preferred hosts: +25 if trusted, -5 if unknown
- MIME type: +5 for PNG/JPEG, -10 for GIF
- Resolution: +10 for high res, -10 for low res
- File size: -5 if very small
LLM Selection
After scoring, LLM picks the best image from top candidates based on:
- Title and URL metadata
- Scoring reasons
- Selection criteria
The LLM evaluates authenticity, clarity, and relevance for technical audiences.
Obsidian Integration
When in an Obsidian vault:
- Auto-detects vault root via
.obsidianfolder - Uses configured attachments folder (default:
Attachments) - Generates Obsidian-style embeds:
![[image.png|alt text]] - Creates backup before modifying notes
Script Files
| File | Purpose |
|---|---|
google_image_search.py | Main entry point |
api.py | Google Custom Search API |
config.py | Credentials and config handling |
download.py | Image download with magic bytes |
evaluate.py | Keyword-based scoring |
llm_select.py | LLM selection and term extraction |
obsidian.py | Vault detection and enrichment |
output.py | Markdown output generation |
JSON Config Reference
This document describes the JSON configuration format for batch image searches.
Config Structure
The config file is a JSON array of entry objects:
[
{ /* entry 1 */ },
{ /* entry 2 */ },
...
]Entry Fields
Required Fields
| Field | Type | Description |
|---|---|---|
query | string | The Google search query |
Recommended Fields
| Field | Type | Default | Description |
|---|---|---|---|
id | string | auto | Unique identifier, used for filenames |
heading | string | query | Display heading in output |
description | string | - | Context about what image to find |
numResults | int | 5 | Number of results to fetch (1-10) |
Selection Fields
| Field | Type | Default | Description |
|---|---|---|---|
selectionCriteria | string | - | What makes a good image for this topic |
selectionCount | int | 2 | How many top candidates to consider for LLM selection |
Scoring Fields
| Field | Type | Description |
|---|---|---|
requiredTerms | string[] | Terms that MUST appear in title/URL (missing = -80 score) |
optionalTerms | string[] | Bonus terms that improve score (+5 each) |
excludeTerms | string[] | Terms to penalize (-50 each) |
preferredHosts | string[] | Trusted domains (+25 if match) |
API Fields
| Field | Type | Description |
|---|---|---|
imgType | string | Image type: clipart, face, lineart, stock, photo, animated |
rights | string | License filter: cc_publicdomain, cc_attribute, cc_sharealike, cc_noncommercial, cc_nonderived |
safe | string | SafeSearch: active, moderate, off (default: active) |
fileType | string | File type filter: jpg, gif, png, bmp, svg, webp, ico, raw |
siteSearch | string | Restrict to specific site |
Example Config
[
{
"id": "alterego-device",
"heading": "AlterEgo Silent Speech Interface",
"description": "Wearable device for silent speech recognition using sEMG signals",
"query": "AlterEgo MIT silent speech interface wearable",
"numResults": 5,
"selectionCriteria": "Real photo of device being worn, not concept art",
"selectionCount": 3,
"requiredTerms": ["AlterEgo"],
"optionalTerms": ["MIT", "wearable", "speech"],
"excludeTerms": ["stock", "illustration", "concept"],
"preferredHosts": ["mit.edu", "media.mit.edu"],
"safe": "active"
},
{
"id": "semg-electrodes",
"heading": "Surface EMG Electrodes",
"description": "Medical-grade surface electrodes for muscle signal detection",
"query": "surface EMG electrodes medical grade",
"numResults": 5,
"selectionCriteria": "Clear photo showing electrode placement on skin",
"requiredTerms": ["electrode"],
"optionalTerms": ["EMG", "surface", "medical"],
"excludeTerms": ["clipart", "diagram"]
}
]Scoring Algorithm
Each image is scored based on config criteria:
Base score: 0
Required terms:
All present: +30
Any missing: -80
Optional terms:
+5 per match
Exclude terms:
-50 per match
Preferred hosts:
Match: +25
No match: -5
MIME type:
JPEG/PNG: +5
GIF: -10
Resolution:
>= 600x400: +10
< 300x300: -10
File size:
< 20KB: -5LLM Selection
After scoring, top selectionCount candidates are sent to LLM with:
- Title
- URL
- Host
- Score
- Scoring reasons
- Selection criteria
LLM returns JSON:
{
"chosen_index": 1,
"explanation": "Best shows actual device in use"
}Environment Variables
Required in .env or environment:
Google-Custom-Search-JSON-API-KEY=AIza...
Google-Custom-Search-CX=327...
OPENROUTER_API_KEY=sk-or-...Alternative key names also supported:
GOOGLE_CUSTOM_SEARCH_API_KEYGOOGLE_CUSTOM_SEARCH_CXOPENROUTER-API-KEY
"""Google Custom Search API wrapper."""
import json
from typing import Any, Dict, Optional
from urllib import error, parse, request
API_ENDPOINT = "https://customsearch.googleapis.com/customsearch/v1"
def build_request_url(
*,
api_key: str,
cx: str,
query: str,
num: int,
img_type: Optional[str] = None,
rights: Optional[str] = None,
safe: Optional[str] = None,
file_type: Optional[str] = None,
site: Optional[str] = None,
) -> str:
"""Build the Google Custom Search API request URL."""
params = {
"key": api_key,
"cx": cx,
"q": query,
"searchType": "image",
"num": max(1, min(num, 10)),
}
if img_type:
params["imgType"] = img_type
if rights:
params["rights"] = rights
if safe:
params["safe"] = safe
if file_type:
params["fileType"] = file_type
if site:
params["siteSearch"] = site
return f"{API_ENDPOINT}?{parse.urlencode(params)}"
def http_get(url: str) -> Dict[str, Any]:
"""Make HTTP GET request and return JSON response."""
req = request.Request(url)
try:
with request.urlopen(req) as resp:
charset = resp.headers.get_content_charset() or "utf-8"
payload = resp.read().decode(charset)
except error.HTTPError as http_err:
detail = http_err.read().decode("utf-8", errors="ignore")
raise RuntimeError(
f"HTTP {http_err.code} error for URL {url}: {detail}"
) from http_err
except error.URLError as url_err:
raise RuntimeError(f"Failed to reach API: {url_err}") from url_err
return json.loads(payload)
def extract_host(url: Optional[str]) -> Optional[str]:
"""Extract hostname from URL."""
if not url:
return None
netloc = parse.urlsplit(url).netloc
return netloc.lower() if netloc else None
def format_result_item(item: Dict[str, Any]) -> Dict[str, Any]:
"""Format a single search result item."""
image_meta = item.get("image", {})
link = item.get("link")
return {
"title": item.get("title"),
"link": link,
"displayLink": item.get("displayLink"),
"contextLink": image_meta.get("contextLink") or item.get("image", {}).get("contextLink"),
"mime": item.get("mime"),
"byteSize": image_meta.get("byteSize"),
"height": image_meta.get("height"),
"width": image_meta.get("width"),
"fileFormat": image_meta.get("fileFormat"),
"thumbnailLink": image_meta.get("thumbnailLink"),
"host": extract_host(link),
}
def fetch_images_for_entry(
*,
entry: Dict[str, Any],
api_key: str,
cx: str,
) -> Dict[str, Any]:
"""Fetch images for a single config entry."""
query = entry["query"]
count = entry.get("numResults", 5)
img_type = entry.get("imgType")
rights = entry.get("rights")
safe = entry.get("safe", "active")
file_type = entry.get("fileType")
site = entry.get("siteSearch")
request_url = build_request_url(
api_key=api_key,
cx=cx,
query=query,
num=count,
img_type=img_type,
rights=rights,
safe=safe,
file_type=file_type,
site=site,
)
data = http_get(request_url)
items = data.get("items", [])
formatted_items = [format_result_item(item) for item in items]
return {
"entry": entry,
"results": formatted_items,
"searchInformation": data.get("searchInformation", {}),
}
def fetch_images_simple(
query: str,
api_key: str,
cx: str,
num_results: int = 5,
) -> Dict[str, Any]:
"""Simple image fetch without full config entry."""
from .config import create_simple_entry
entry = create_simple_entry(query, num_results=num_results)
return fetch_images_for_entry(entry=entry, api_key=api_key, cx=cx)
"""Configuration and credentials handling for Google Image Search skill."""
import json
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
def load_env(path: Path) -> Dict[str, str]:
"""Load a minimal .env file, preserving keys with hyphens."""
env: Dict[str, str] = {}
if not path.exists():
return env
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if "=" not in stripped:
continue
key, value = stripped.split("=", 1)
env[key.strip()] = value.strip()
return env
def resolve_credentials(
api_key: Optional[str] = None,
cx: Optional[str] = None,
env_file: Optional[Path] = None,
) -> Dict[str, str]:
"""Resolve API credentials from args, environment, or .env file."""
env_path = env_file or Path(".env")
env = load_env(env_path)
resolved_api_key = (
api_key
or os.environ.get("GOOGLE_CUSTOM_SEARCH_JSON_API_KEY")
or os.environ.get("GOOGLE_CUSTOM_SEARCH_API_KEY")
or env.get("Google-Custom-Search-JSON-API-KEY")
or env.get("GOOGLE_CUSTOM_SEARCH_API_KEY")
)
resolved_cx = (
cx
or os.environ.get("GOOGLE_CUSTOM_SEARCH_CX")
or env.get("Google-Custom-Search-CX")
or env.get("GOOGLE_CUSTOM_SEARCH_CX")
)
return {"api_key": resolved_api_key or "", "cx": resolved_cx or ""}
def get_openrouter_key(env_file: Optional[Path] = None) -> Optional[str]:
"""Get OpenRouter API key from environment or .env file."""
env_path = env_file or Path(".env")
env = load_env(env_path)
return (
os.environ.get("OPENROUTER_API_KEY")
or os.environ.get("OPENROUTER-API-KEY")
or env.get("OPENROUTER_API_KEY")
or env.get("OPENROUTER-API-KEY")
)
def load_queries(config_path: Path) -> List[Dict[str, Any]]:
"""Load query entries from JSON config file."""
data = json.loads(config_path.read_text(encoding="utf-8"))
if not isinstance(data, list):
raise ValueError("Config must be a list of query entries")
return data
def create_simple_entry(
query: str,
heading: Optional[str] = None,
num_results: int = 5,
selection_count: int = 2,
) -> Dict[str, Any]:
"""Create a config entry from a simple query string."""
# Use query as heading if not provided
entry_heading = heading or query.split()[0].title() if query else "Image"
return {
"id": query.lower().replace(" ", "-")[:40],
"heading": entry_heading,
"description": f"Image search for: {query}",
"query": query,
"numResults": num_results,
"selectionCount": selection_count,
"safe": "active",
}
def create_entry_from_term(
term: str,
description: Optional[str] = None,
selection_criteria: Optional[str] = None,
required_terms: Optional[List[str]] = None,
optional_terms: Optional[List[str]] = None,
exclude_terms: Optional[List[str]] = None,
preferred_hosts: Optional[List[str]] = None,
num_results: int = 5,
selection_count: int = 2,
) -> Dict[str, Any]:
"""Create a full config entry for a term with optional criteria."""
entry = {
"id": term.lower().replace(" ", "-")[:40],
"heading": term,
"description": description or f"Visual representation of {term}",
"query": term,
"numResults": num_results,
"selectionCount": selection_count,
"safe": "active",
}
if selection_criteria:
entry["selectionCriteria"] = selection_criteria
if required_terms:
entry["requiredTerms"] = required_terms
if optional_terms:
entry["optionalTerms"] = optional_terms
if exclude_terms:
entry["excludeTerms"] = exclude_terms
if preferred_hosts:
entry["preferredHosts"] = preferred_hosts
return entry
def save_config(entries: List[Dict[str, Any]], output_path: Path) -> None:
"""Save config entries to JSON file."""
output_path.write_text(
json.dumps(entries, indent=2, ensure_ascii=False),
encoding="utf-8"
)
"""Image downloading with magic byte detection for proper extensions."""
import mimetypes
import os
import re
from pathlib import Path
from typing import Any, Dict, Iterable, Optional, Tuple
from urllib import error, parse, request
def detect_image_type_from_bytes(data: bytes) -> Optional[str]:
"""Detect image type from magic bytes."""
signatures = {
b'\x89PNG\r\n\x1a\n': '.png',
b'\xff\xd8\xff': '.jpg',
b'GIF87a': '.gif',
b'GIF89a': '.gif',
b'RIFF': '.webp', # WebP starts with RIFF....WEBP
b'BM': '.bmp',
}
for sig, ext in signatures.items():
if data.startswith(sig):
if ext == '.webp' and b'WEBP' not in data[:12]:
continue
return ext
return None
def slugify(value: str, fallback: str = "image") -> str:
"""Create URL-safe slug from string."""
text = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return text or fallback
def pick_extension(link: str, mime: Optional[str]) -> str:
"""Determine file extension from MIME type or URL."""
if mime:
ext = mimetypes.guess_extension(mime.split(";")[0].strip())
if ext in {".jpe", ".jpeg"}:
return ".jpg"
if ext:
return ext
path = parse.urlsplit(link).path
_, ext = os.path.splitext(path)
if ext:
return ext
return ".bin"
def download_single_image(url: str, dest: Path) -> Tuple[bool, Optional[str]]:
"""Download a single image to destination path."""
req = request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
try:
with request.urlopen(req, timeout=30) as resp:
data = resp.read()
except error.URLError as err:
return False, f"Failed to download {url}: {err}"
except TimeoutError:
return False, f"Timeout downloading {url}"
dest.write_bytes(data)
return True, None
def download_all_images(
results: Iterable[Dict[str, Any]],
download_dir: Path,
) -> int:
"""Download all images from results, detecting proper extensions."""
download_dir.mkdir(parents=True, exist_ok=True)
total_downloaded = 0
for bundle in results:
heading = bundle["entry"].get("heading") or bundle["entry"].get("id", "section")
section_slug = slugify(heading)
for idx, item in enumerate(bundle["results"], start=1):
link = item.get("link")
if not link:
continue
ext = pick_extension(link, item.get("mime"))
filename = f"{section_slug}-{idx:02d}{ext}"
destination = download_dir / filename
success, err = download_single_image(link, destination)
if success:
# If extension unknown, detect from magic bytes and rename
if ext == ".bin":
data = destination.read_bytes()
detected_ext = detect_image_type_from_bytes(data)
if detected_ext:
new_destination = destination.with_suffix(detected_ext)
destination.rename(new_destination)
destination = new_destination
item["localPath"] = str(destination)
total_downloaded += 1
else:
item["downloadError"] = err
return total_downloaded
def download_best_images(
results: Iterable[Dict[str, Any]],
download_dir: Path,
) -> int:
"""Download only the best (final choice or top scored) images."""
download_dir.mkdir(parents=True, exist_ok=True)
total_downloaded = 0
for bundle in results:
heading = bundle["entry"].get("heading") or bundle["entry"].get("id", "section")
section_slug = slugify(heading)
# Find best image: finalChoice or top scored
best_item = None
for item in bundle["results"]:
if item.get("finalChoice"):
best_item = item
break
if not best_item and bundle["results"]:
# Fall back to highest scored
sorted_items = sorted(
bundle["results"],
key=lambda x: x.get("evaluation", {}).get("score", float("-inf")),
reverse=True,
)
best_item = sorted_items[0]
if not best_item or not best_item.get("link"):
continue
link = best_item["link"]
ext = pick_extension(link, best_item.get("mime"))
filename = f"{section_slug}{ext}"
destination = download_dir / filename
success, err = download_single_image(link, destination)
if success:
if ext == ".bin":
data = destination.read_bytes()
detected_ext = detect_image_type_from_bytes(data)
if detected_ext:
new_destination = destination.with_suffix(detected_ext)
destination.rename(new_destination)
destination = new_destination
best_item["localPath"] = str(destination)
total_downloaded += 1
else:
best_item["downloadError"] = err
return total_downloaded
"""Keyword-based scoring for image search results."""
from typing import Any, Dict, Iterable, List
def keyword_match(text: str, keywords: Iterable[str]) -> Dict[str, bool]:
"""Check which keywords are present in text."""
lowered = text.lower()
return {kw: (kw.lower() in lowered) for kw in keywords}
def evaluate_item(item: Dict[str, Any], entry: Dict[str, Any]) -> Dict[str, Any]:
"""Evaluate a single image result against entry criteria."""
score = 0
reasons: List[str] = []
# Combine text fields for keyword matching
combined_text = " ".join(
filter(
None,
[
item.get("title"),
item.get("displayLink"),
item.get("contextLink"),
entry.get("description"),
],
)
)
# Required terms
required = entry.get("requiredTerms", [])
if required:
matches = keyword_match(combined_text, required)
missing = [kw for kw, ok in matches.items() if not ok]
if missing:
score -= 80
reasons.append(f"missing required: {', '.join(missing)}")
else:
score += 30
reasons.append("contains all required terms")
# Optional terms
optional = entry.get("optionalTerms", [])
if optional:
matches = keyword_match(combined_text, optional)
present = [kw for kw, ok in matches.items() if ok]
if present:
boost = 5 * len(present)
score += boost
reasons.append(f"optional terms: {', '.join(present)} (+{boost})")
# Exclude terms
exclude = entry.get("excludeTerms", [])
if exclude:
matches = keyword_match(combined_text, exclude)
present = [kw for kw, ok in matches.items() if ok]
if present:
penalty = 50 * len(present)
score -= penalty
reasons.append(f"excluded terms present: {', '.join(present)} (-{penalty})")
# Preferred hosts
host = item.get("host")
preferred_hosts: List[str] = [
host_name.lower() for host_name in entry.get("preferredHosts", [])
]
if host and preferred_hosts:
if any(host_name in host for host_name in preferred_hosts):
score += 25
reasons.append(f"trusted host: {host}")
else:
score -= 5
reasons.append(f"unlisted host: {host}")
# MIME type preference
mime = item.get("mime") or item.get("fileFormat")
if mime:
if "jpeg" in mime.lower() or "png" in mime.lower():
score += 5
reasons.append("preferred mime")
elif "gif" in mime.lower():
score -= 10
reasons.append("gif penalized")
# Resolution scoring
width = item.get("width") or 0
height = item.get("height") or 0
if width and height:
if width >= 600 and height >= 400:
score += 10
reasons.append("high resolution")
elif width < 300 or height < 300:
score -= 10
reasons.append("low resolution")
# File size scoring
byte_size = item.get("byteSize") or 0
if byte_size and byte_size < 20_000:
score -= 5
reasons.append("small file size")
return {"score": score, "reasons": reasons}
def evaluate_results(results: Iterable[Dict[str, Any]]) -> None:
"""Evaluate all results in place."""
for bundle in results:
entry = bundle["entry"]
for item in bundle["results"]:
item["evaluation"] = evaluate_item(item, entry)
def get_top_candidates(
bundle: Dict[str, Any],
count: int = 2,
) -> List[Dict[str, Any]]:
"""Get top N candidates by score from a bundle."""
sorted_items = sorted(
bundle["results"],
key=lambda item: item.get("evaluation", {}).get("score", float("-inf")),
reverse=True,
)
return sorted_items[:count]
#!/usr/bin/env python3
"""Google Image Search skill - main orchestration script.
Modes:
1. Simple query: --query "search term"
2. Batch from JSON: --config queries.json
3. Generate config: --generate-config --terms "term1" "term2"
4. Note enrichment: --enrich-note note.md
"""
import argparse
import json
import sys
from pathlib import Path
from typing import List, Optional
# Add scripts directory to path for relative imports
SCRIPT_DIR = Path(__file__).parent
sys.path.insert(0, str(SCRIPT_DIR))
from api import fetch_images_for_entry
from config import (
create_simple_entry,
get_openrouter_key,
load_queries,
resolve_credentials,
save_config,
)
from download import download_all_images, download_best_images
from evaluate import evaluate_results
from llm_select import (
extract_visual_terms,
generate_config_from_terms,
run_llm_selection,
)
from obsidian import (
detect_obsidian_vault,
enrich_note_with_images,
extract_headings,
get_attachments_folder,
map_terms_to_headings,
)
from output import (
emit_final_selection_markdown,
emit_preview_markdown,
emit_selection_markdown,
emit_summary_markdown,
emit_urls_only,
)
def run_simple_query(
query: str,
api_key: str,
cx: str,
num_results: int,
output_dir: Optional[Path],
llm_select: bool,
llm_executable: Path,
llm_model: str,
openrouter_key: Optional[str],
urls_only: bool,
) -> None:
"""Run a simple single-query search."""
entry = create_simple_entry(query, num_results=num_results)
bundle = fetch_images_for_entry(entry=entry, api_key=api_key, cx=cx)
results = [bundle]
evaluate_results(results)
if llm_select:
run_llm_selection(
results=results,
llm_executable=llm_executable,
model=llm_model,
openrouter_key=openrouter_key,
)
if urls_only:
print(emit_urls_only(results, best_only=True))
return
if output_dir:
downloaded = download_best_images(results, output_dir)
print(f"Downloaded {downloaded} image(s) to {output_dir}")
print(emit_final_selection_markdown(results) if llm_select else emit_selection_markdown(results))
def run_batch(
config_path: Path,
api_key: str,
cx: str,
output_dir: Optional[Path],
llm_select: bool,
llm_executable: Path,
llm_model: str,
openrouter_key: Optional[str],
urls_only: bool,
output_files: dict,
download_all: bool,
limit: Optional[int],
) -> None:
"""Run batch search from JSON config."""
entries = load_queries(config_path)
if limit:
entries = entries[:limit]
results = []
for entry in entries:
bundle = fetch_images_for_entry(entry=entry, api_key=api_key, cx=cx)
results.append(bundle)
evaluate_results(results)
if llm_select:
run_llm_selection(
results=results,
llm_executable=llm_executable,
model=llm_model,
openrouter_key=openrouter_key,
)
if urls_only:
print(emit_urls_only(results, best_only=True))
return
if output_dir:
if download_all:
downloaded = download_all_images(results, output_dir)
else:
downloaded = download_best_images(results, output_dir)
print(f"Downloaded {downloaded} image(s) to {output_dir}")
# Write output files
if output_files.get("summary"):
Path(output_files["summary"]).write_text(emit_summary_markdown(results), encoding="utf-8")
if output_files.get("preview"):
Path(output_files["preview"]).write_text(emit_preview_markdown(results, prefer_local=bool(output_dir)), encoding="utf-8")
if output_files.get("selection"):
Path(output_files["selection"]).write_text(emit_selection_markdown(results), encoding="utf-8")
if output_files.get("final") and llm_select:
Path(output_files["final"]).write_text(emit_final_selection_markdown(results), encoding="utf-8")
written = [f for f in output_files.values() if f]
if written:
print(f"Wrote: {', '.join(written)}")
def run_generate_config(
terms: List[str],
output_path: Path,
llm_executable: Path,
llm_model: str,
openrouter_key: Optional[str],
num_results: int,
) -> None:
"""Generate JSON config from list of terms using LLM."""
print(f"Generating config for {len(terms)} terms...")
entries = generate_config_from_terms(
terms=terms,
llm_executable=llm_executable,
model=llm_model,
openrouter_key=openrouter_key,
num_results=num_results,
)
save_config(entries, output_path)
print(f"Wrote config with {len(entries)} entries to {output_path}")
def run_enrich_note(
note_path: Path,
api_key: str,
cx: str,
llm_executable: Path,
llm_model: str,
openrouter_key: Optional[str],
num_results: int,
attachments_folder: Optional[Path],
dry_run: bool,
) -> None:
"""Enrich Obsidian note with images."""
if not note_path.exists():
print(f"Error: Note not found: {note_path}", file=sys.stderr)
sys.exit(1)
note_content = note_path.read_text(encoding="utf-8")
# Detect Obsidian vault
vault_root = detect_obsidian_vault(note_path)
if vault_root:
print(f"Detected Obsidian vault: {vault_root}")
if not attachments_folder:
attachments_folder = get_attachments_folder(vault_root)
else:
if not attachments_folder:
attachments_folder = note_path.parent / "images"
print(f"Attachments folder: {attachments_folder}")
# Extract headings
headings = extract_headings(note_content)
print(f"Found {len(headings)} headings")
# Extract visual terms using LLM
print("Extracting visual terms...")
terms = extract_visual_terms(
note_content=note_content,
llm_executable=llm_executable,
model=llm_model,
openrouter_key=openrouter_key,
)
if not terms:
print("No visual terms extracted. Note may be too short or abstract.")
return
print(f"Extracted {len(terms)} terms: {[t.get('term') for t in terms]}")
if dry_run:
print("\n[DRY RUN] Would search for:")
for term in terms:
print(f" - {term.get('term')} (heading: {term.get('heading', 'general')})")
return
# Map terms to headings
terms_by_heading = map_terms_to_headings(terms, headings)
# Create config entries and fetch images
# Use term as ID for unique filenames, track target heading separately
results = []
term_to_heading = {} # Maps entry ID to target heading
for heading, heading_terms in terms_by_heading.items():
for term_info in heading_terms:
term_id = term_info.get("term", "image").lower().replace(" ", "-")[:40]
entry = {
"id": term_id,
"heading": term_id, # Use term for filename slug
"description": term_info.get("description", ""),
"query": term_info.get("term", ""),
"selectionCriteria": term_info.get("criteria", ""),
"numResults": num_results,
"selectionCount": 2,
"safe": "active",
}
term_to_heading[term_id] = heading # Track target heading for insertion
bundle = fetch_images_for_entry(entry=entry, api_key=api_key, cx=cx)
results.append(bundle)
# Evaluate and select
evaluate_results(results)
run_llm_selection(
results=results,
llm_executable=llm_executable,
model=llm_model,
openrouter_key=openrouter_key,
)
# Download best images
attachments_folder.mkdir(parents=True, exist_ok=True)
downloaded = download_best_images(results, attachments_folder)
print(f"Downloaded {downloaded} images")
# Build images_by_heading for note enrichment
# Use list of tuples to handle multiple images per heading
images_by_heading = {}
for bundle in results:
entry = bundle["entry"]
term_id = entry.get("id", "")
target_heading = term_to_heading.get(term_id, "")
final_selection = entry.get("finalSelection")
if final_selection and target_heading:
# Only keep first image per heading to avoid clutter
if target_heading not in images_by_heading:
images_by_heading[target_heading] = final_selection["item"]
# Enrich note
enriched_content = enrich_note_with_images(
note_path=note_path,
images_by_heading=images_by_heading,
attachments_folder=attachments_folder,
use_obsidian_embeds=bool(vault_root),
create_backup_file=True,
)
note_path.write_text(enriched_content, encoding="utf-8")
print(f"Enriched note: {note_path}")
print(f"Backup created: {note_path}.bak")
def main() -> None:
parser = argparse.ArgumentParser(
description="Google Image Search skill - search, download, and integrate images"
)
# Mode selection
mode_group = parser.add_mutually_exclusive_group(required=True)
mode_group.add_argument("--query", help="Simple query mode: search for single term")
mode_group.add_argument("--config", type=Path, help="Batch mode: JSON config file")
mode_group.add_argument("--generate-config", action="store_true", help="Generate config from terms")
mode_group.add_argument("--enrich-note", type=Path, help="Enrich Obsidian note with images")
# Terms for config generation
parser.add_argument("--terms", nargs="+", help="Terms for config generation")
# Output options
parser.add_argument("--output-dir", type=Path, help="Directory to save images")
parser.add_argument("--output", type=Path, help="Output file for generated config or markdown")
parser.add_argument("--urls-only", action="store_true", help="Output URLs only, no download")
parser.add_argument("--download-all", action="store_true", help="Download all images, not just best")
# Output files for batch mode
parser.add_argument("--summary-output", default="image_suggestions.md")
parser.add_argument("--preview-output", default="image_preview.md")
parser.add_argument("--selection-output", default="image_selection.md")
parser.add_argument("--final-output", default="image_final_selection.md")
# API options
parser.add_argument("--env-file", type=Path, default=Path(".env"))
parser.add_argument("--api-key", help="Google Custom Search API key")
parser.add_argument("--cx", help="Google Custom Search Engine ID")
parser.add_argument("--num-results", type=int, default=5, help="Results per query")
# LLM options
parser.add_argument("--llm-select", action="store_true", default=True, help="Use LLM for selection (default)")
parser.add_argument("--no-llm-select", action="store_true", help="Disable LLM selection")
parser.add_argument("--llm-executable", type=Path, default=Path("/opt/homebrew/bin/llm"))
parser.add_argument("--llm-model", default="openrouter/openai/gpt-4o-mini")
# Obsidian options
parser.add_argument("--attachments-folder", type=Path, help="Override attachments folder")
# Other options
parser.add_argument("--dry-run", action="store_true", help="Show what would be done")
parser.add_argument("--limit", type=int, help="Limit number of queries (batch mode)")
args = parser.parse_args()
# Resolve credentials
credentials = resolve_credentials(
api_key=args.api_key,
cx=args.cx,
env_file=args.env_file,
)
api_key = credentials["api_key"]
cx = credentials["cx"]
if not api_key and not args.generate_config:
print("Error: Missing API key", file=sys.stderr)
sys.exit(1)
if not cx and not args.generate_config:
print("Error: Missing Search Engine ID (cx)", file=sys.stderr)
sys.exit(1)
openrouter_key = get_openrouter_key(args.env_file)
llm_select = args.llm_select and not args.no_llm_select
# Route to appropriate mode
if args.query:
run_simple_query(
query=args.query,
api_key=api_key,
cx=cx,
num_results=args.num_results,
output_dir=args.output_dir,
llm_select=llm_select,
llm_executable=args.llm_executable,
llm_model=args.llm_model,
openrouter_key=openrouter_key,
urls_only=args.urls_only,
)
elif args.config:
run_batch(
config_path=args.config,
api_key=api_key,
cx=cx,
output_dir=args.output_dir,
llm_select=llm_select,
llm_executable=args.llm_executable,
llm_model=args.llm_model,
openrouter_key=openrouter_key,
urls_only=args.urls_only,
output_files={
"summary": args.summary_output,
"preview": args.preview_output,
"selection": args.selection_output,
"final": args.final_output,
},
download_all=args.download_all,
limit=args.limit,
)
elif args.generate_config:
if not args.terms:
print("Error: --terms required with --generate-config", file=sys.stderr)
sys.exit(1)
output_path = args.output or Path("generated_queries.json")
run_generate_config(
terms=args.terms,
output_path=output_path,
llm_executable=args.llm_executable,
llm_model=args.llm_model,
openrouter_key=openrouter_key,
num_results=args.num_results,
)
elif args.enrich_note:
run_enrich_note(
note_path=args.enrich_note,
api_key=api_key,
cx=cx,
llm_executable=args.llm_executable,
llm_model=args.llm_model,
openrouter_key=openrouter_key,
num_results=args.num_results,
attachments_folder=args.attachments_folder,
dry_run=args.dry_run,
)
if __name__ == "__main__":
main()
"""LLM-based image selection and term extraction."""
import json
import os
import subprocess
import textwrap
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
DEFAULT_LLM_EXECUTABLE = Path("/opt/homebrew/bin/llm")
DEFAULT_LLM_MODEL = "openrouter/openai/gpt-4o-mini"
DEFAULT_LLM_PROVIDER = "openrouter"
DEFAULT_SELECTION_PROMPT = textwrap.dedent(
"""
You are an expert fact-checking researcher picking the single best real-world image or
research figure for a technical presentation. Prioritize authenticity, clarity, and
alignment with the provided selection criteria. Prefer reputable sources, avoid stock
art, concept renders, diagrams (unless the criteria call for figures), and low-quality
or suspicious images. When in doubt, choose the option that best illustrates the topic
for a skeptical technical audience.
Respond strictly with JSON: {"chosen_index": <integer>, "explanation": "<short reason>"}.
"""
).strip()
DEFAULT_TERM_EXTRACTION_PROMPT = textwrap.dedent(
"""
You are an expert at identifying concepts in text that would benefit from visual illustration.
Analyze the provided note and extract terms/concepts that:
1. Would be clearer with an image (technical diagrams, physical devices, people, places)
2. Are concrete enough to find relevant images for
3. Are significant to the document's content
For each term, provide:
- term: the search query
- heading: which section heading it belongs under (or null if general)
- description: brief context for what kind of image would be helpful
- criteria: what makes a good image for this term
Respond with JSON array: [{"term": "...", "heading": "...", "description": "...", "criteria": "..."}, ...]
Extract 3-8 terms maximum, focusing on the most visually impactful concepts.
"""
).strip()
def parse_json_from_response(text: str) -> Optional[Dict[str, Any]]:
"""Parse JSON from LLM response, handling markdown code blocks."""
text = text.strip()
if not text:
return None
# Try direct parse
if text.startswith("{") and text.endswith("}"):
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# Try to find JSON object in response
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
snippet = text[start : end + 1]
try:
return json.loads(snippet)
except json.JSONDecodeError:
pass
return None
def parse_json_array_from_response(text: str) -> Optional[List[Dict[str, Any]]]:
"""Parse JSON array from LLM response."""
text = text.strip()
if not text:
return None
# Remove markdown code blocks if present
if "```json" in text:
text = text.split("```json")[1].split("```")[0].strip()
elif "```" in text:
text = text.split("```")[1].split("```")[0].strip()
# Try direct parse
if text.startswith("[") and text.endswith("]"):
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# Try to find JSON array in response
start = text.find("[")
end = text.rfind("]")
if start != -1 and end != -1 and end > start:
snippet = text[start : end + 1]
try:
return json.loads(snippet)
except json.JSONDecodeError:
pass
return None
def run_llm(
prompt: str,
system_prompt: str,
llm_executable: Path = DEFAULT_LLM_EXECUTABLE,
model: str = DEFAULT_LLM_MODEL,
provider: Optional[str] = DEFAULT_LLM_PROVIDER,
openrouter_key: Optional[str] = None,
) -> tuple[bool, str]:
"""Run LLM with prompt and return (success, output)."""
if not llm_executable.exists():
return False, f"LLM executable not found: {llm_executable}"
cmd = [
str(llm_executable),
"prompt",
prompt,
"-m",
model,
"-n",
"--no-stream",
"-s",
system_prompt,
]
# Only add provider if model doesn't already include provider prefix
if provider and "/" not in model:
cmd.extend(["-o", f"provider={provider}"])
llm_env = os.environ.copy()
if openrouter_key:
llm_env.setdefault("OPENROUTER_API_KEY", openrouter_key)
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
check=False,
env=llm_env,
timeout=120,
)
except subprocess.TimeoutExpired:
return False, "LLM request timed out"
if proc.returncode != 0:
return False, proc.stderr.strip() or "Unknown LLM error"
return True, proc.stdout
def run_llm_selection(
*,
results: Iterable[Dict[str, Any]],
llm_executable: Path = DEFAULT_LLM_EXECUTABLE,
model: str = DEFAULT_LLM_MODEL,
system_prompt: str = DEFAULT_SELECTION_PROMPT,
openrouter_key: Optional[str] = None,
provider: Optional[str] = DEFAULT_LLM_PROVIDER,
) -> None:
"""Use LLM to select best image from candidates for each entry."""
for bundle in results:
entry = bundle["entry"]
candidates = sorted(
bundle["results"],
key=lambda item: item.get("evaluation", {}).get("score", float("-inf")),
reverse=True,
)
selection_count = entry.get("selectionCount", 2)
if selection_count <= 0:
continue
candidates = candidates[: max(selection_count, 1)]
if not candidates:
continue
# Build prompt
prompt_lines = [
f"Topic: {entry.get('heading') or entry.get('id', 'Unnamed')}",
]
criteria = entry.get("selectionCriteria") or entry.get("description")
if criteria:
prompt_lines.append(f"Selection criteria: {criteria}")
prompt_lines.append("Candidates:")
for idx, item in enumerate(candidates, start=1):
eval_data = item.get("evaluation", {})
reasons = "; ".join(eval_data.get("reasons", [])) or "(no reasons)"
prompt_lines.append(
textwrap.dedent(
f"""
Candidate {idx}:
Title: {item.get('title') or 'Untitled'}
URL: {item.get('link')}
Host: {item.get('host') or 'unknown'}
Score: {eval_data.get('score', 'N/A')}
Reasons: {reasons}
"""
).strip()
)
prompt_lines.append(
"Choose the single best candidate index that meets the criteria and explain briefly."
)
prompt = "\n".join(prompt_lines)
success, output = run_llm(
prompt=prompt,
system_prompt=system_prompt,
llm_executable=llm_executable,
model=model,
provider=provider,
openrouter_key=openrouter_key,
)
if not success:
entry["finalSelection"] = {
"item": candidates[0],
"explanation": f"LLM selection failed: {output}",
"fallback": True,
}
candidates[0]["finalChoice"] = True
candidates[0]["finalChoiceReason"] = entry["finalSelection"]["explanation"]
continue
parsed = parse_json_from_response(output)
if not parsed or "chosen_index" not in parsed:
entry["finalSelection"] = {
"item": candidates[0],
"explanation": "LLM response unreadable; defaulted to top-scoring candidate",
"fallback": True,
}
candidates[0]["finalChoice"] = True
candidates[0]["finalChoiceReason"] = entry["finalSelection"]["explanation"]
continue
chosen_index = parsed.get("chosen_index")
explanation = parsed.get("explanation", "")
try:
chosen_idx = int(chosen_index)
except (TypeError, ValueError):
chosen_idx = 1
if chosen_idx < 1 or chosen_idx > len(candidates):
chosen_idx = 1
winner = candidates[chosen_idx - 1]
winner["finalChoice"] = True
winner["finalChoiceReason"] = explanation or "Chosen by LLM"
entry["finalSelection"] = {
"item": winner,
"explanation": explanation or "Chosen by LLM",
"fallback": False,
}
def extract_visual_terms(
note_content: str,
llm_executable: Path = DEFAULT_LLM_EXECUTABLE,
model: str = DEFAULT_LLM_MODEL,
system_prompt: str = DEFAULT_TERM_EXTRACTION_PROMPT,
openrouter_key: Optional[str] = None,
provider: Optional[str] = DEFAULT_LLM_PROVIDER,
) -> List[Dict[str, Any]]:
"""Use LLM to extract visual-worthy terms from note content."""
# Truncate very long notes
max_chars = 8000
if len(note_content) > max_chars:
note_content = note_content[:max_chars] + "\n\n[... truncated ...]"
prompt = f"Analyze this note and extract terms that would benefit from images:\n\n{note_content}"
success, output = run_llm(
prompt=prompt,
system_prompt=system_prompt,
llm_executable=llm_executable,
model=model,
provider=provider,
openrouter_key=openrouter_key,
)
if not success:
return []
terms = parse_json_array_from_response(output)
return terms if terms else []
def generate_config_from_terms(
terms: List[str],
llm_executable: Path = DEFAULT_LLM_EXECUTABLE,
model: str = DEFAULT_LLM_MODEL,
openrouter_key: Optional[str] = None,
provider: Optional[str] = DEFAULT_LLM_PROVIDER,
num_results: int = 5,
) -> List[Dict[str, Any]]:
"""Use LLM to generate full config entries from a list of terms."""
system_prompt = textwrap.dedent(
"""
Generate image search config entries for the provided terms.
For each term, create an entry with:
- id: slugified term
- heading: human-readable title
- description: what kind of image to find
- query: optimized search query
- selectionCriteria: what makes a good image
- requiredTerms: terms that MUST appear (usually the main subject)
- optionalTerms: bonus terms that improve relevance
- excludeTerms: terms to avoid (stock photo, clipart, etc.)
Respond with JSON array of entries.
"""
).strip()
prompt = f"Generate image search configs for these terms:\n{json.dumps(terms)}"
success, output = run_llm(
prompt=prompt,
system_prompt=system_prompt,
llm_executable=llm_executable,
model=model,
provider=provider,
openrouter_key=openrouter_key,
)
if not success:
# Fallback to simple entries
from .config import create_entry_from_term
return [create_entry_from_term(term, num_results=num_results) for term in terms]
entries = parse_json_array_from_response(output)
if not entries:
from .config import create_entry_from_term
return [create_entry_from_term(term, num_results=num_results) for term in terms]
# Ensure required fields
for entry in entries:
entry.setdefault("numResults", num_results)
entry.setdefault("selectionCount", 2)
entry.setdefault("safe", "active")
return entries
"""Obsidian vault detection and note enrichment."""
import json
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
def detect_obsidian_vault(path: Path) -> Optional[Path]:
"""Check if path is in an Obsidian vault, return vault root."""
current = path.resolve()
if current.is_file():
current = current.parent
while current != current.parent:
if (current / ".obsidian").is_dir():
return current
current = current.parent
return None
def get_attachments_folder(vault_root: Path, default: str = "Attachments") -> Path:
"""Get configured attachments folder from vault settings."""
app_json = vault_root / ".obsidian" / "app.json"
if app_json.exists():
try:
settings = json.loads(app_json.read_text(encoding="utf-8"))
attachment_path = settings.get("attachmentFolderPath", default)
if attachment_path:
return vault_root / attachment_path
except (json.JSONDecodeError, KeyError):
pass
return vault_root / default
def extract_headings(note_content: str) -> List[Tuple[str, int, int]]:
"""Extract markdown headings with their levels and line positions.
Returns: [(heading_text, level, line_number), ...]
"""
headings = []
lines = note_content.split("\n")
for line_num, line in enumerate(lines):
match = re.match(r"^(#{1,6})\s+(.+)$", line)
if match:
level = len(match.group(1))
text = match.group(2).strip()
headings.append((text, level, line_num))
return headings
def extract_frontmatter(note_content: str) -> Tuple[Optional[Dict], str]:
"""Extract YAML frontmatter from note content.
Returns: (frontmatter_dict or None, content_without_frontmatter)
"""
if not note_content.startswith("---"):
return None, note_content
lines = note_content.split("\n")
end_idx = None
for i, line in enumerate(lines[1:], start=1):
if line.strip() == "---":
end_idx = i
break
if end_idx is None:
return None, note_content
frontmatter_lines = lines[1:end_idx]
content_lines = lines[end_idx + 1:]
# Simple YAML parsing (key: value)
frontmatter = {}
for line in frontmatter_lines:
if ":" in line:
key, _, value = line.partition(":")
frontmatter[key.strip()] = value.strip()
return frontmatter, "\n".join(content_lines)
def find_heading_line(note_content: str, heading_text: str) -> Optional[int]:
"""Find line number for a specific heading."""
headings = extract_headings(note_content)
for text, _, line_num in headings:
if text.lower() == heading_text.lower():
return line_num
return None
def insert_image_after_heading(
note_content: str,
heading_text: str,
image_embed: str,
) -> str:
"""Insert image embed after specified heading."""
lines = note_content.split("\n")
heading_line = find_heading_line(note_content, heading_text)
if heading_line is None:
# Heading not found, append at end
return note_content + "\n\n" + image_embed
# Insert after heading line
insert_pos = heading_line + 1
# Skip any empty lines right after heading
while insert_pos < len(lines) and not lines[insert_pos].strip():
insert_pos += 1
# Insert image with blank lines
lines.insert(insert_pos, "")
lines.insert(insert_pos + 1, image_embed)
lines.insert(insert_pos + 2, "")
return "\n".join(lines)
def insert_images_below_headings(
note_content: str,
images: Dict[str, str], # {heading: image_embed}
) -> str:
"""Insert image embeds below relevant headings.
Args:
note_content: The note content
images: Dict mapping heading text to image embed string
"""
for heading, embed in images.items():
note_content = insert_image_after_heading(note_content, heading, embed)
return note_content
def format_obsidian_embed(
filename: str,
alt_text: Optional[str] = None,
width: Optional[int] = None,
) -> str:
"""Format as Obsidian-style image embed.
Examples:
![[image.png]]
![[image.png|alt text]]
![[image.png|500]]
"""
if alt_text and width:
return f"![[{filename}|{alt_text}|{width}]]"
elif alt_text:
return f"![[{filename}|{alt_text}]]"
elif width:
return f"![[{filename}|{width}]]"
else:
return f"![[{filename}]]"
def format_standard_embed(
path_or_url: str,
alt_text: str = "Image",
) -> str:
"""Format as standard markdown image embed."""
# Escape brackets in alt text
alt_text = alt_text.replace("[", "(").replace("]", ")")
return f""
def find_best_heading_match(
target: str,
headings: List[str],
) -> Optional[str]:
"""Find best matching heading using partial matching."""
if not target or not headings:
return None
target_lower = target.lower()
# Try exact match first
for h in headings:
if h.lower() == target_lower:
return h
# Try if target is contained in heading (handles "1. AI Safety" matching "AI Safety")
for h in headings:
if target_lower in h.lower():
return h
# Try if heading is contained in target
for h in headings:
if h.lower() in target_lower:
return h
# Try word overlap
target_words = set(target_lower.split())
best_match = None
best_overlap = 0
for h in headings:
h_words = set(h.lower().split())
overlap = len(target_words & h_words)
if overlap > best_overlap:
best_overlap = overlap
best_match = h
if best_overlap >= 2: # At least 2 words overlap
return best_match
return None
def map_terms_to_headings(
terms: List[Dict[str, Any]],
headings: List[Tuple[str, int, int]],
) -> Dict[str, List[Dict[str, Any]]]:
"""Map extracted terms to their target headings using fuzzy matching.
Returns: {heading_text: [term_entries]}
"""
heading_texts = [h[0] for h in headings]
result: Dict[str, List[Dict[str, Any]]] = {}
for term in terms:
target_heading = term.get("heading")
# Try fuzzy matching
matched_heading = find_best_heading_match(target_heading, heading_texts)
if matched_heading:
result.setdefault(matched_heading, []).append(term)
elif heading_texts:
# Assign to first heading if no match found
result.setdefault(heading_texts[0], []).append(term)
else:
# No headings in document
result.setdefault("", []).append(term)
return result
def create_backup(note_path: Path) -> Path:
"""Create backup of note before modification."""
backup_path = note_path.with_suffix(note_path.suffix + ".bak")
backup_path.write_text(note_path.read_text(encoding="utf-8"), encoding="utf-8")
return backup_path
def enrich_note_with_images(
note_path: Path,
images_by_heading: Dict[str, Dict[str, Any]], # {heading: best_image_item}
attachments_folder: Path,
use_obsidian_embeds: bool = True,
create_backup_file: bool = True,
) -> str:
"""Enrich note by inserting images below headings.
Args:
note_path: Path to the note file
images_by_heading: Dict mapping heading text to best image item
attachments_folder: Where images are saved
use_obsidian_embeds: Use ![[]] format vs standard markdown
create_backup_file: Create .bak file before modifying
Returns: Modified note content
"""
note_content = note_path.read_text(encoding="utf-8")
if create_backup_file:
create_backup(note_path)
image_embeds = {}
for heading, item in images_by_heading.items():
local_path = item.get("localPath")
if not local_path:
continue
filename = Path(local_path).name
alt_text = item.get("title", "Image")
if use_obsidian_embeds:
embed = format_obsidian_embed(filename, alt_text)
else:
# Use relative path from note to attachments
embed = format_standard_embed(local_path, alt_text)
image_embeds[heading] = embed
return insert_images_below_headings(note_content, image_embeds)
"""Markdown output generation for image search results."""
from pathlib import Path
from typing import Any, Dict, Iterable, List
def format_alt_text(text: str) -> str:
"""Format text for use as markdown alt text (escape brackets)."""
return text.replace("[", "(").replace("]", ")")
def emit_summary_markdown(results: Iterable[Dict[str, Any]]) -> str:
"""Generate summary markdown with links and metadata."""
lines: List[str] = []
for bundle in results:
entry = bundle["entry"]
heading = entry.get("heading") or entry.get("id", "Unnamed")
description = entry.get("description")
lines.append(f"### {heading}")
if description:
lines.append(description)
for item in bundle["results"]:
link = item.get("link")
title = item.get("title")
display = item.get("displayLink")
context = item.get("contextLink")
mime = item.get("mime")
detail_parts = [display] if display else []
if mime:
detail_parts.append(mime)
score = item.get("evaluation", {}).get("score")
if score is not None:
detail_parts.append(f"score={score}")
if item.get("finalChoice"):
detail_parts.append("final")
detail = ", ".join(detail_parts)
lines.append(f"- {title or 'Untitled'} ({detail})\n {link}")
if context and context != link:
lines.append(f" Source page: {context}")
reasons = item.get("evaluation", {}).get("reasons")
if reasons:
lines.append(" Reasons: " + "; ".join(reasons))
final_reason = item.get("finalChoiceReason")
if final_reason:
lines.append(f" Final pick rationale: {final_reason}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def emit_preview_markdown(
results: Iterable[Dict[str, Any]],
*,
prefer_local: bool = False,
) -> str:
"""Generate preview markdown with inline images."""
lines: List[str] = []
for bundle in results:
entry = bundle["entry"]
heading = entry.get("heading") or entry.get("id", "Unnamed")
description = entry.get("description")
lines.append(f"### {heading}")
if description:
lines.append(description)
for idx, item in enumerate(bundle["results"], start=1):
raw_title = item.get("title") or f"Image {idx}"
title = format_alt_text(raw_title)
image_target = (
item.get("localPath")
if prefer_local and item.get("localPath")
else item.get("link")
)
if not image_target:
continue
source = item.get("contextLink") or item.get("link")
lines.append(f"")
if source and source != image_target:
lines.append(f"[Source]({source})")
score = item.get("evaluation", {}).get("score")
if score is not None:
lines.append(f"Score: {score}")
reasons = item.get("evaluation", {}).get("reasons")
if reasons:
lines.append("Reasons: " + "; ".join(reasons))
final_reason = item.get("finalChoiceReason")
if final_reason:
lines.append(f"Final pick: {final_reason}")
lines.append("")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def emit_selection_markdown(results: Iterable[Dict[str, Any]]) -> str:
"""Generate markdown showing top-scoring selections."""
lines: List[str] = []
for bundle in results:
entry = bundle["entry"]
heading = entry.get("heading") or entry.get("id", "Unnamed")
criteria = entry.get("selectionCriteria")
selection_count = entry.get("selectionCount", 1)
lines.append(f"### {heading}")
if criteria:
lines.append(f"Criteria: {criteria}")
sorted_items = sorted(
bundle["results"],
key=lambda item: item.get("evaluation", {}).get("score", float("-inf")),
reverse=True,
)
for idx, item in enumerate(sorted_items[:selection_count], start=1):
title = item.get("title") or f"Image {idx}"
link = item.get("link")
score = item.get("evaluation", {}).get("score")
lines.append(f"{idx}. {title} - score {score}")
lines.append(f" Link: {link}")
local_path = item.get("localPath")
if local_path:
lines.append(f" Local: {local_path}")
source = item.get("contextLink")
if source and source != link:
lines.append(f" Source: {source}")
reasons = item.get("evaluation", {}).get("reasons")
if reasons:
lines.append(f" Reasons: {', '.join(reasons)}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def emit_final_selection_markdown(results: Iterable[Dict[str, Any]]) -> str:
"""Generate markdown showing only final LLM-selected images."""
lines: List[str] = []
for bundle in results:
entry = bundle["entry"]
heading = entry.get("heading") or entry.get("id", "Unnamed")
criteria = entry.get("selectionCriteria") or entry.get("description")
lines.append(f"### {heading}")
if criteria:
lines.append(f"Criteria: {criteria}")
selection = entry.get("finalSelection")
if not selection:
lines.append("No final selection was made.")
lines.append("")
continue
item = selection["item"]
title = item.get("title") or "Untitled"
link = item.get("link")
lines.append(f"Chosen image: {title}")
lines.append(f"Link: {link}")
local_path = item.get("localPath")
if local_path:
lines.append(f"Local file: {local_path}")
source = item.get("contextLink")
if source and source != link:
lines.append(f"Source page: {source}")
lines.append(f"LLM explanation: {selection['explanation']}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def emit_urls_only(results: Iterable[Dict[str, Any]], best_only: bool = True) -> str:
"""Generate simple list of image URLs."""
lines: List[str] = []
for bundle in results:
entry = bundle["entry"]
heading = entry.get("heading") or entry.get("id", "Unnamed")
lines.append(f"# {heading}")
if best_only:
# Find final choice or top scored
best_item = None
for item in bundle["results"]:
if item.get("finalChoice"):
best_item = item
break
if not best_item and bundle["results"]:
sorted_items = sorted(
bundle["results"],
key=lambda x: x.get("evaluation", {}).get("score", float("-inf")),
reverse=True,
)
best_item = sorted_items[0]
if best_item:
lines.append(best_item.get("link", ""))
else:
for item in bundle["results"]:
lines.append(item.get("link", ""))
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def emit_obsidian_embed(
item: Dict[str, Any],
use_local: bool = True,
) -> str:
"""Generate Obsidian-style image embed."""
if use_local and item.get("localPath"):
path = Path(item["localPath"])
filename = path.name
alt_text = format_alt_text(item.get("title") or "Image")
return f"![[{filename}|{alt_text}]]"
else:
link = item.get("link", "")
alt_text = format_alt_text(item.get("title") or "Image")
return f""
Related skills
How it compares
Choose google-image-search over manual stock-site browsing when you need API-driven batch downloads and LLM-ranked picks wired into Obsidian or article workflows.
FAQ
What API does google-image-search use?
google-image-search uses the Google Custom Search API to retrieve image candidates, then applies intelligent scoring and LLM-based selection before download. Developers need valid Custom Search API credentials configured for the skill’s search and batch workflows.
Can google-image-search enrich Obsidian notes in bulk?
google-image-search supports full Obsidian note enrichment with automatic image insertion below headings, plus batch processing from JSON config files. The skill can also auto-generate search configs from supplied terms for large vault updates.
Is Google Image Search safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.