mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
Complete EPUB creation, editing, validation, and knowledge extraction skill for the Agent Skills open format. Built from spec research, real EPUB testing on 2.1MB commercial Apress title, and Apple Books compatibility verification on macOS 26. Scripts (11): epub-scaffold — Create valid EPUB3 with cover XHTML, Apple Books CSS epub-edit — Surgical editing (8 subcommands, epublib) epub-info — Structure/metadata dump as JSON epub-text — Clean text extraction, per-chapter or single-file epub-extract-knowledge — Heuristic + LLM extraction (env var auto-detect) epub-validate — EPUBCheck or Python fallback validation epub-images — List/extract all images with cover detection epub-batch — Multi-file processing (extract-text, validate, metadata) epub-convert — EPUB2→EPUB3 conversion with validation epub-repair — Diagnose & auto-fix common structural issues epub-cover — Add cover XHTML wrapper for Apple Books compatibility References (9): epub-format-internals.md, python-libraries.md, spec-and-validation.md, tutorials-and-guides.md, agent-capability-discovery.md, fixed-layout-epub.md, accessibility.md, media-overlays.md, apple-books-compatibility.md (NEW — verified on macOS 26) Test: 46/46 passing (test_epub_skill.sh)
432 lines
14 KiB
Python
Executable File
432 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""epub-extract-knowledge — Extract key passages, facts, quotes, and arguments from EPUB content.
|
|
|
|
Usage:
|
|
epub-extract-knowledge <file.epub> [--format atoms|memory|json]
|
|
[--prompt "custom extraction prompt"] [--no-llm]
|
|
[--output PATH] [--json] [--dry-run]
|
|
|
|
LLM mode (automatic when env vars are set):
|
|
Set EPUB_LLM_URL and EPUB_LLM_KEY to enable LLM extraction.
|
|
Optional: EPUB_LLM_MODEL (default: auto-detected from provider).
|
|
|
|
export EPUB_LLM_URL="https://opencode.ai/zen/go/v1"
|
|
export EPUB_LLM_KEY="sk-..."
|
|
export EPUB_LLM_MODEL="deepseek-v4-flash" # optional
|
|
|
|
epub-extract-knowledge book.epub --format json # LLM mode, auto-detected
|
|
epub-extract-knowledge book.epub --format json --no-llm # force heuristic
|
|
|
|
Without env vars, falls back to heuristic extraction automatically.
|
|
|
|
Output formats:
|
|
atoms — Obsidian vault atom templates (YAML frontmatter + body)
|
|
memory — Memory entries (key-value, suitable for agent memory)
|
|
json — Raw structured JSON
|
|
|
|
Dependencies: EbookLib, beautifulsoup4. Optional: requests (for LLM mode).
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import warnings
|
|
|
|
warnings.filterwarnings("ignore")
|
|
|
|
DRY_RUN = False
|
|
JSON_OUTPUT = False
|
|
LLM_CONFIG = None
|
|
|
|
|
|
def die(msg):
|
|
print(f"Error: {msg}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def warn(msg):
|
|
print(f"Warning: {msg}", file=sys.stderr)
|
|
|
|
|
|
def emit(json_data, text=""):
|
|
if JSON_OUTPUT:
|
|
print(json.dumps(json_data, indent=2, default=str))
|
|
else:
|
|
print(text)
|
|
|
|
|
|
def load_llm_config():
|
|
"""Check environment for LLM configuration. Returns dict or None."""
|
|
url = os.environ.get('EPUB_LLM_URL', '').strip()
|
|
key = os.environ.get('EPUB_LLM_KEY', '').strip()
|
|
if url and key:
|
|
return {
|
|
'url': url.rstrip('/'),
|
|
'key': key,
|
|
'model': os.environ.get('EPUB_LLM_MODEL', '').strip() or None,
|
|
}
|
|
return None
|
|
|
|
|
|
def call_llm(messages, config):
|
|
"""Call an OpenAI-compatible API. Returns response text or None on failure."""
|
|
try:
|
|
import requests
|
|
except ImportError:
|
|
warn("requests library not available for LLM calls. Install with: pip install requests")
|
|
return None
|
|
|
|
url = f"{config['url']}/chat/completions"
|
|
headers = {
|
|
'Authorization': f"Bearer {config['key']}",
|
|
'Content-Type': 'application/json',
|
|
}
|
|
body = {
|
|
'messages': messages,
|
|
'temperature': 0.3,
|
|
'max_tokens': 2000,
|
|
}
|
|
if config['model']:
|
|
body['model'] = config['model']
|
|
|
|
try:
|
|
resp = requests.post(url, headers=headers, json=body, timeout=120)
|
|
if resp.status_code == 200:
|
|
return resp.json()['choices'][0]['message']['content']
|
|
else:
|
|
warn(f"LLM call returned {resp.status_code}: {resp.text[:200]}")
|
|
return None
|
|
except Exception as e:
|
|
warn(f"LLM call failed: {e}")
|
|
return None
|
|
|
|
|
|
def extract_text_from_epub(epub_path):
|
|
"""Extract reading-order text from an EPUB file."""
|
|
try:
|
|
from ebooklib import epub
|
|
import ebooklib
|
|
from bs4 import BeautifulSoup
|
|
except ImportError:
|
|
die("EbookLib and beautifulsoup4 are required. Install with: pip install EbookLib beautifulsoup4")
|
|
|
|
book = epub.read_epub(epub_path)
|
|
|
|
spine_ids = []
|
|
try:
|
|
spine_ids = [item_id for item_id, _ in book.spine]
|
|
except Exception:
|
|
pass
|
|
|
|
item_map = {}
|
|
for item in book.get_items():
|
|
iid = getattr(item, 'id', None)
|
|
if iid:
|
|
item_map[iid] = item
|
|
|
|
chapters = []
|
|
for idx, item_id in enumerate(spine_ids):
|
|
item = item_map.get(item_id)
|
|
if not item or not hasattr(item, 'get_type') or item.get_type() != ebooklib.ITEM_DOCUMENT:
|
|
continue
|
|
try:
|
|
content = item.get_content().decode('utf-8', errors='replace')
|
|
except Exception:
|
|
warn(f"Could not decode {item_id}")
|
|
continue
|
|
|
|
soup = BeautifulSoup(content, 'html.parser')
|
|
for element in soup(['script', 'style', 'nav']):
|
|
element.decompose()
|
|
|
|
paragraphs = []
|
|
for p in soup.find_all(['p', 'h1', 'h2', 'h3', 'h4', 'li']):
|
|
text = p.get_text(strip=True)
|
|
if text and len(text) > 20:
|
|
paragraphs.append({
|
|
'tag': p.name,
|
|
'text': text,
|
|
'char_count': len(text)
|
|
})
|
|
|
|
title = getattr(item, 'title', f'Chapter {idx + 1}') or f'Chapter {idx + 1}'
|
|
chapters.append({
|
|
'index': idx,
|
|
'title': title,
|
|
'paragraphs': paragraphs
|
|
})
|
|
|
|
return chapters
|
|
|
|
|
|
def heuristic_extract(chapters):
|
|
"""Extract knowledge using heuristics (no LLM)."""
|
|
insights = []
|
|
|
|
for chapter in chapters:
|
|
for para in chapter['paragraphs']:
|
|
text = para['text']
|
|
tag = para['tag']
|
|
|
|
if tag in ('h1', 'h2', 'h3'):
|
|
insights.append({
|
|
'type': 'fact',
|
|
'source_chapter': chapter['title'],
|
|
'content': text,
|
|
'context': 'heading'
|
|
})
|
|
continue
|
|
|
|
if '**' in text or '__' in text:
|
|
insights.append({
|
|
'type': 'key_point',
|
|
'source_chapter': chapter['title'],
|
|
'content': text,
|
|
'context': 'emphasized'
|
|
})
|
|
continue
|
|
|
|
if tag == 'li':
|
|
insights.append({
|
|
'type': 'fact',
|
|
'source_chapter': chapter['title'],
|
|
'content': text,
|
|
'context': 'list_item'
|
|
})
|
|
continue
|
|
|
|
definition_markers = ['is defined as', 'refers to', 'means', 'is a', 'are the',
|
|
'in other words', 'that is,', 'i.e.', 'e.g.']
|
|
if len(text) > 80 and any(marker in text.lower() for marker in definition_markers):
|
|
insights.append({
|
|
'type': 'definition',
|
|
'source_chapter': chapter['title'],
|
|
'content': text,
|
|
'context': 'body'
|
|
})
|
|
continue
|
|
|
|
if len(text) > 200:
|
|
insights.append({
|
|
'type': 'argument',
|
|
'source_chapter': chapter['title'],
|
|
'content': text[:500],
|
|
'context': 'body'
|
|
})
|
|
|
|
# Deduplicate by content
|
|
seen = set()
|
|
unique = []
|
|
for insight in insights:
|
|
key = insight['content'][:100]
|
|
if key not in seen:
|
|
seen.add(key)
|
|
unique.append(insight)
|
|
|
|
return unique
|
|
|
|
|
|
def llm_extract(chapters, prompt, config):
|
|
"""Extract knowledge using LLM."""
|
|
all_insights = []
|
|
|
|
for i, chapter in enumerate(chapters):
|
|
# Only process chapters with substantial content (>500 chars of paragraph text)
|
|
total_text = '\n\n'.join(p['text'] for p in chapter['paragraphs'])
|
|
if len(total_text) < 500:
|
|
continue
|
|
|
|
# Chunk if necessary (>3000 chars → split into chunks)
|
|
chunks = _chunk_text(total_text, 3000)
|
|
warn(f" [{i+1}/{len(chapters)}] {chapter['title']}: {len(total_text)} chars → {len(chunks)} chunk(s)")
|
|
|
|
for chunk_idx, chunk in enumerate(chunks):
|
|
messages = [
|
|
{'role': 'system', 'content': prompt},
|
|
{'role': 'user', 'content': f"Chapter: {chapter['title']}\n\n{chunk}"},
|
|
]
|
|
|
|
response = call_llm(messages, config)
|
|
if not response:
|
|
continue
|
|
|
|
# Parse JSON from LLM response
|
|
try:
|
|
json_match = re.search(r'\[.*\]', response, re.DOTALL)
|
|
if json_match:
|
|
parsed = json.loads(json_match.group())
|
|
for item in parsed:
|
|
if isinstance(item, dict) and 'content' in item:
|
|
item['source_chapter'] = chapter['title']
|
|
all_insights.append(item)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
return all_insights
|
|
|
|
|
|
def _chunk_text(text, max_chars):
|
|
"""Split text into chunks of max_chars, breaking at paragraph boundaries."""
|
|
paragraphs = text.split('\n\n')
|
|
chunks = []
|
|
current = []
|
|
current_len = 0
|
|
|
|
for para in paragraphs:
|
|
if current_len + len(para) > max_chars and current:
|
|
chunks.append('\n\n'.join(current))
|
|
current = []
|
|
current_len = 0
|
|
current.append(para)
|
|
current_len += len(para)
|
|
|
|
if current:
|
|
chunks.append('\n\n'.join(current))
|
|
|
|
return chunks or [text]
|
|
|
|
|
|
def format_atom(insight):
|
|
"""Format an insight as an Obsidian vault atom."""
|
|
title = insight['content'][:60].replace('\n', ' ')
|
|
slug = re.sub(r'[^a-z0-9]+', '-', title.lower()).strip('-')
|
|
|
|
frontmatter = f'''---
|
|
created: {_today()}
|
|
type: atom
|
|
topics: []
|
|
publish: false
|
|
---
|
|
'''
|
|
body = f'''# {title}
|
|
|
|
**Source chapter:** {insight['source_chapter']}
|
|
**Type:** {insight.get('type', 'unknown')}
|
|
|
|
{insight['content']}
|
|
'''
|
|
return {
|
|
'filename': f"{slug}.md",
|
|
'content': frontmatter + body
|
|
}
|
|
|
|
|
|
def format_memory(insight):
|
|
"""Format an insight as a memory entry."""
|
|
return {
|
|
'type': insight.get('type', 'unknown'),
|
|
'title': insight['content'][:80],
|
|
'content': insight['content'],
|
|
'source': insight['source_chapter']
|
|
}
|
|
|
|
|
|
def _today():
|
|
from datetime import date
|
|
return date.today().isoformat()
|
|
|
|
|
|
def main():
|
|
global DRY_RUN, JSON_OUTPUT, LLM_CONFIG
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description='Extract knowledge from EPUB content — facts, quotes, arguments.',
|
|
epilog='Examples:\n epub-extract-knowledge book.epub --format json\n epub-extract-knowledge book.epub --no-llm --format atoms')
|
|
parser.add_argument('epub', nargs='?', help='Path to EPUB file')
|
|
parser.add_argument('--format', choices=['atoms', 'memory', 'json'], default='json',
|
|
help='Output format (default: json)')
|
|
parser.add_argument('--no-llm', action='store_true', help='Force heuristic mode (ignore env vars)')
|
|
parser.add_argument('--prompt', help='Custom extraction prompt (for LLM mode)')
|
|
parser.add_argument('--output', '-o', help='Output file path (for atoms/memory modes)')
|
|
parser.add_argument('--json', action='store_true', help='Output summary as JSON')
|
|
parser.add_argument('--dry-run', '-n', action='store_true', help='Preview without reading file')
|
|
args = parser.parse_args()
|
|
|
|
JSON_OUTPUT = args.json
|
|
DRY_RUN = args.dry_run
|
|
|
|
if not args.epub:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
if not os.path.exists(args.epub):
|
|
die(f"File not found: {args.epub}")
|
|
|
|
# Detect LLM config
|
|
LLM_CONFIG = load_llm_config()
|
|
use_llm = LLM_CONFIG is not None and not args.no_llm
|
|
|
|
if use_llm:
|
|
warn(f"LLM mode enabled: {LLM_CONFIG['url']} (model={LLM_CONFIG.get('model') or 'default'})")
|
|
|
|
if DRY_RUN:
|
|
mode = 'llm' if use_llm else 'heuristic'
|
|
emit({
|
|
'status': 'dry_run',
|
|
'file': args.epub,
|
|
'format': args.format,
|
|
'mode': mode
|
|
}, f'[dry-run] Would extract knowledge from: {args.epub} (mode={mode})')
|
|
return
|
|
|
|
chapters = extract_text_from_epub(args.epub)
|
|
|
|
default_prompt = """Extract substantive knowledge from the following book chapter. Return a JSON array of objects, each with:
|
|
- "type": one of "fact", "definition", "argument", "key_point"
|
|
- "content": the extracted passage (verbatim, 50-300 words)
|
|
- "context": why this matters or what it relates to
|
|
|
|
Focus on technical concepts, architectural patterns, key arguments, definitions, and actionable insights. Skip frontmatter, copyright text, table of contents, and author bios. Skip generic marketing language. Extract only content that would be useful for reference or learning."""
|
|
|
|
prompt = args.prompt or default_prompt
|
|
|
|
if use_llm:
|
|
insights = llm_extract(chapters, prompt, LLM_CONFIG)
|
|
if not insights:
|
|
warn("LLM extraction produced no results — falling back to heuristic mode")
|
|
insights = heuristic_extract(chapters)
|
|
mode = 'heuristic (fallback)'
|
|
else:
|
|
mode = 'llm'
|
|
else:
|
|
insights = heuristic_extract(chapters)
|
|
mode = 'heuristic'
|
|
|
|
summary = {
|
|
'file': args.epub,
|
|
'chapters_processed': len(chapters),
|
|
'insights_found': len(insights),
|
|
'mode': mode,
|
|
'by_type': {}
|
|
}
|
|
for i in insights:
|
|
summary['by_type'][i['type']] = summary['by_type'].get(i['type'], 0) + 1
|
|
|
|
if args.format == 'atoms':
|
|
atoms = [format_atom(i) for i in insights]
|
|
if args.output:
|
|
os.makedirs(args.output, exist_ok=True)
|
|
for a in atoms:
|
|
with open(os.path.join(args.output, a['filename']), 'w') as f:
|
|
f.write(a['content'])
|
|
summary['output_dir'] = args.output
|
|
summary['atoms_written'] = len(atoms)
|
|
output = [a['content'] for a in atoms]
|
|
emit(summary, '\n\n---\n\n'.join(output[:5]) +
|
|
(f"\n\n... and {len(output) - 5} more" if len(output) > 5 else ""))
|
|
|
|
elif args.format == 'memory':
|
|
memories = [format_memory(i) for i in insights]
|
|
summary['memories'] = memories
|
|
print(json.dumps(summary, indent=2, default=str))
|
|
|
|
else: # json
|
|
summary['insights'] = insights
|
|
print(json.dumps(summary, indent=2, default=str))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|