video-lens-gallery Manage and browse your saved video-lens reports. Step 1 — Locate skill scripts Discover both ( ) and ( ) using the standard 8-agent discovery loop: Step 2 — Backfill metadata (only if requested) If the user's request mentions "backfill", run: Step 3 — Rebuild index Check that the reports directory exists before running: Tell the user the number of reports indexed, from the script's output. Step 4 — Serve gallery Tell the user the gallery is now available at . ---

, '', raw, flags=re.IGNORECASE)\n return unescape_html(raw)\n return \"\"\n\n\n_DURATION_RE = re.compile(r'^\\d+\\s*(min|h\\b)', re.IGNORECASE)\n_DATE_RE = re.compile(\n r'^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d+\\s+\\d{4}

video-lens-gallery Manage and browse your saved video-lens reports. Step 1 — Locate skill scripts Discover both ( ) and ( ) using the standard 8-agent discovery loop: Step 2 — Backfill metadata (only if requested) If the user's request mentions "backfill", run: Step 3 — Rebuild index Check that the reports directory exists before running: Tell the user the number of reports indexed, from the script's output. Step 4 — Serve gallery Tell the user the gallery is now available at . ---

\n r'|^\\d{4}-\\d{2}-\\d{2}

video-lens-gallery Manage and browse your saved video-lens reports. Step 1 — Locate skill scripts Discover both ( ) and ( ) using the standard 8-agent discovery loop: Step 2 — Backfill metadata (only if requested) If the user's request mentions "backfill", run: Step 3 — Rebuild index Check that the reports directory exists before running: Tell the user the number of reports indexed, from the script's output. Step 4 — Serve gallery Tell the user the gallery is now available at . ---

,\n re.IGNORECASE,\n)\n\n\ndef _looks_like_duration_or_date(s: str) -> bool:\n return bool(_DURATION_RE.match(s) or _DATE_RE.match(s))\n\n\ndef extract_meta_line_parts(html: str) -> tuple[str, str, str]:\n \"\"\"Extract channel, duration, publishDate from the meta-line element.\"\"\"\n # \u003cp class=\"meta-line\">Channel · Duration · Date · Views\u003c/p>\n m = re.search(r'class=\"meta-line\"[^>]*>(.*?)\u003c/p>', html, re.DOTALL | re.IGNORECASE)\n if not m:\n return \"\", \"\", \"\"\n raw = re.sub(r'\u003c[^>]+>', '', m.group(1)).strip()\n raw = unescape_html(raw) # converts · → · (and other entities)\n parts = [p.strip() for p in raw.split('·')]\n # Drop trailing \"Open on YouTube ↗\" fragment that the template appends after META_LINE\n parts = [p for p in parts if '↗' not in p and 'youtube' not in p.lower()]\n channel = parts[0] if len(parts) > 0 else \"\"\n duration = parts[1] if len(parts) > 1 else \"\"\n pub_date = parts[2] if len(parts) > 2 else \"\"\n # If channel looks like a duration or date, META_LINE had no channel prefix — shift\n if _looks_like_duration_or_date(channel):\n duration = parts[0] if len(parts) > 0 else \"\"\n pub_date = parts[1] if len(parts) > 1 else \"\"\n channel = \"\"\n return channel, duration, pub_date\n\n\ndef extract_summary(html: str) -> str:\n \"\"\"Extract plain text of the first \u003cp> in the summary section.\"\"\"\n m = re.search(r'id=\"summary\".*?\u003cp>(.*?)\u003c/p>', html, re.DOTALL | re.IGNORECASE)\n if m:\n raw = re.sub(r'\u003c[^>]+>', '', m.group(1)).strip()\n return unescape_html(raw[:400])\n return \"\"\n\n\ndef extract_keywords(html: str) -> list[str]:\n \"\"\"Extract \u003cstrong> headline text from key-points section.\"\"\"\n kp_m = re.search(r'id=\"key-points\"(.*?)\u003c/section>', html, re.DOTALL | re.IGNORECASE)\n if not kp_m:\n return []\n kp_html = kp_m.group(1)\n strongs = re.findall(r'\u003cstrong>(.*?)\u003c/strong>', kp_html, re.DOTALL)\n keywords = []\n for s in strongs:\n plain = re.sub(r'\u003c[^>]+>', '', s).strip()\n plain = unescape_html(plain)\n # Strip \" — ...\" suffix that sometimes appears in the strong tag\n plain = re.split(r'\\s+[—–]\\s+', plain)[0].strip()\n if plain:\n keywords.append(plain)\n return keywords[:10]\n\n\ndef parse_gen_date(filename: str) -> str:\n \"\"\"Parse YYYY-MM-DD from filename like 2026-03-06-210126-video-lens_*.html\"\"\"\n m = re.match(r'^(\\d{4}-\\d{2}-\\d{2})', filename)\n return m.group(1) if m else \"\"\n\n\ndef unescape_html(s: str) -> str:\n \"\"\"Basic HTML entity unescaping.\"\"\"\n replacements = {\n '&': '&', '<': '\u003c', '>': '>',\n '"': '\"', ''': \"'\", ''': \"'\",\n '—': '—', '–': '–', '“': '\"', '”': '\"',\n '‘': \"'\", '’': \"'\", '…': '…', '·': '·',\n }\n for entity, char in replacements.items():\n s = s.replace(entity, char)\n # Numeric entities\n s = re.sub(r'&#(\\d+);', lambda m: chr(int(m.group(1))), s)\n return s\n\n\ndef backfill_file(path: pathlib.Path, dry_run: bool) -> bool:\n \"\"\"Add video-lens-meta block to a report file. Returns True if modified.\"\"\"\n html = path.read_text(encoding=\"utf-8\", errors=\"replace\")\n\n if META_SCRIPT_START in html:\n return False # already has meta block\n\n video_id = extract_video_id(html)\n title = extract_title(html)\n channel, duration, pub_date = extract_meta_line_parts(html)\n gen_date = parse_gen_date(path.name)\n summary = extract_summary(html)\n keywords = extract_keywords(html)\n\n meta_obj = {\n \"videoId\": video_id,\n \"title\": title,\n \"channel\": channel,\n \"duration\": duration,\n \"publishDate\": pub_date,\n \"generationDate\": gen_date,\n \"summary\": summary,\n \"tags\": [], # cannot generate retroactively\n \"keywords\": keywords,\n \"filename\": path.name,\n }\n\n meta_block = f'{META_SCRIPT_START}{json.dumps(meta_obj, ensure_ascii=False)}\u003c/script>'\n new_html = html.replace('\u003c/body>', f'{meta_block}\\n\u003c/body>', 1)\n\n if new_html == html:\n print(f\"WARN: could not find \u003c/body> in {path.name}\", file=sys.stderr)\n return False\n\n if not dry_run:\n path.write_text(new_html, encoding=\"utf-8\")\n\n return True\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"Backfill video-lens-meta into existing reports\")\n parser.add_argument(\"--dir\", required=True, help=\"Directory containing video-lens HTML reports\")\n parser.add_argument(\"--dry-run\", action=\"store_true\", help=\"Print what would be changed without writing\")\n args = parser.parse_args()\n\n scan_dir = pathlib.Path(args.dir).expanduser().resolve()\n if not scan_dir.is_dir():\n print(f\"ERROR: directory not found: {scan_dir}\", file=sys.stderr)\n sys.exit(1)\n\n # Reports live in scan_dir/reports/ since the directory reorganisation, but\n # older reports may still sit at the legacy flat location. Scan both.\n candidates = list(scan_dir.glob(\"*video-lens*.html\"))\n candidates.extend((scan_dir / \"reports\").glob(\"*video-lens*.html\"))\n seen: set[str] = set()\n report_files: list[pathlib.Path] = []\n for p in sorted(candidates):\n if p.name in seen:\n continue\n seen.add(p.name)\n report_files.append(p)\n\n modified = 0\n skipped = 0\n\n for path in report_files:\n if path.name == \"index.html\":\n continue\n changed = backfill_file(path, dry_run=args.dry_run)\n if changed:\n modified += 1\n action = \"would update\" if args.dry_run else \"updated\"\n print(f\"{action}: {path.name}\")\n else:\n skipped += 1\n\n print(f\"\\nDone: {modified} {'would be ' if args.dry_run else ''}updated, {skipped} already had meta block or skipped.\")\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":7467,"content_sha256":"edbe01be419460315fe8b50b9bb6566999eae7189b921e6415ff2ead500a5f8f"},{"filename":"scripts/build_index.py","content":"#!/usr/bin/env python3\n\"\"\"Build a manifest.json index from video-lens HTML reports in a directory.\n\nUsage: python3 build_index.py --dir DIR [--output DIR]\n\nScans DIR for *video-lens*.html files, extracts the embedded\n\u003cscript id=\"video-lens-meta\"> JSON block from each, and writes manifest.json\nto --output (defaults to --dir). Also copies index.html to the output directory\nif it is not already present.\n\"\"\"\nimport argparse\nimport json\nimport pathlib\nimport re\nimport shutil\nimport sys\nfrom datetime import datetime, timezone\n\n\nSCRIPT_START = '\u003cscript type=\"application/json\" id=\"video-lens-meta\">'\nSCRIPT_END = \"\u003c/script>\"\n\n_CHAN_DURATION_RE = re.compile(r'^\\d+\\s*(min|h\\b)', re.IGNORECASE)\n_CHAN_DATE_RE = re.compile(\n r'^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+\\d',\n re.IGNORECASE,\n)\n\n\ndef _sanitize_channel(value: str) -> str:\n \"\"\"Return empty string if value is obviously not a channel name.\"\"\"\n if not value:\n return \"\"\n # Stored as literal HTML entity — full meta-line was used as channel\n if \"·\" in value:\n return \"\"\n # Multiple middle-dots → full meta-line accidentally stored as channel\n if value.count(\"·\") >= 2:\n return \"\"\n # Duration like \"18 min\" or \"1h 16m\"\n if _CHAN_DURATION_RE.match(value):\n return \"\"\n # Date like \"Mar 10 2026\"\n if _CHAN_DATE_RE.match(value):\n return \"\"\n return value\n\n\ndef extract_meta(path: pathlib.Path) -> dict | None:\n \"\"\"Extract the video-lens-meta JSON block from an HTML report file.\"\"\"\n content = path.read_text(encoding=\"utf-8\", errors=\"replace\")\n i = content.find(SCRIPT_START)\n if i == -1:\n return None\n i += len(SCRIPT_START)\n j = content.find(SCRIPT_END, i)\n if j == -1:\n return None\n raw = content[i:j].strip()\n try:\n return json.loads(raw)\n except json.JSONDecodeError as e:\n print(f\"WARNING: invalid JSON in {path.name}: {e}\", file=sys.stderr)\n return None\n\n\ndef find_index_html() -> pathlib.Path | None:\n \"\"\"Find index.html co-located with this script (deployed skill location).\"\"\"\n # In the deployed skill, index.html lives alongside SKILL.md, one level up from scripts/\n candidates = [\n pathlib.Path(__file__).parent.parent / \"index.html\", # skills/video-lens-gallery/index.html\n pathlib.Path(__file__).parent / \"index.html\", # scripts/index.html (fallback)\n ]\n for p in candidates:\n if p.exists():\n return p\n return None\n\n\ndef main():\n parser = argparse.ArgumentParser(description=\"Build video-lens manifest.json\")\n parser.add_argument(\"--dir\", required=True, help=\"Directory containing video-lens HTML reports\")\n parser.add_argument(\"--output\", help=\"Directory to write manifest.json (default: same as --dir)\")\n args = parser.parse_args()\n\n scan_dir = pathlib.Path(args.dir).expanduser().resolve()\n out_dir = pathlib.Path(args.output).expanduser().resolve() if args.output else scan_dir\n\n if not scan_dir.is_dir():\n print(f\"ERROR: directory not found: {scan_dir}\", file=sys.stderr)\n sys.exit(1)\n\n out_dir.mkdir(parents=True, exist_ok=True)\n\n seen = set()\n reports = []\n skipped = 0\n\n # Phase 1: reports/ subdir (new location)\n reports_subdir = scan_dir / \"reports\"\n if reports_subdir.is_dir():\n for path in sorted(reports_subdir.glob(\"*video-lens*.html\"),\n key=lambda p: p.name, reverse=True):\n meta = extract_meta(path)\n if meta is None:\n skipped += 1\n print(f\"SKIP (no meta block): reports/{path.name}\", file=sys.stderr)\n continue\n meta[\"filename\"] = \"reports/\" + path.name\n seen.add(path.name)\n meta[\"channel\"] = _sanitize_channel(meta.get(\"channel\", \"\"))\n reports.append(meta)\n\n # Phase 2: root (backward compat — old flat layout)\n for path in sorted(scan_dir.glob(\"*video-lens*.html\"),\n key=lambda p: p.name, reverse=True):\n if path.name == \"index.html\" or path.name in seen:\n continue\n meta = extract_meta(path)\n if meta is None:\n skipped += 1\n print(f\"SKIP (no meta block): {path.name}\", file=sys.stderr)\n continue\n if not meta.get(\"filename\"):\n meta[\"filename\"] = path.name\n meta[\"channel\"] = _sanitize_channel(meta.get(\"channel\", \"\"))\n reports.append(meta)\n\n # Re-sort combined list newest-first\n reports.sort(key=lambda m: m.get(\"filename\", \"\"), reverse=True)\n\n manifest = {\n \"generated\": datetime.now(timezone.utc).strftime(\"%Y-%m-%dT%H:%M:%SZ\"),\n \"count\": len(reports),\n \"reports\": reports,\n }\n\n manifest_path = out_dir / \"manifest.json\"\n manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding=\"utf-8\")\n print(f\"manifest.json → {manifest_path} ({len(reports)} reports, {skipped} skipped)\")\n\n # Write index.html with manifest inlined as window.__MANIFEST__ so it works\n # from file:// as well as http://localhost:8765/\n index_src = find_index_html()\n index_dst = out_dir / \"index.html\"\n if index_src:\n index_html = index_src.read_text(encoding=\"utf-8\")\n safe_json = json.dumps(manifest, ensure_ascii=False).replace(\"\u003c/\", \"\u003c\\\\/\")\n inline_script = (\n \"\u003cscript>window.__MANIFEST__ = \"\n + safe_json\n + \";\u003c/script>\"\n )\n # Insert inline script just before the first \u003cscript> tag in \u003cbody>\n patched = index_html.replace(\"\u003cscript>\\n(function\", inline_script + \"\\n\u003cscript>\\n(function\", 1)\n if patched == index_html:\n # fallback: inject before \u003c/body>\n patched = index_html.replace(\"\u003c/body>\", inline_script + \"\\n\u003c/body>\", 1)\n index_dst.write_text(patched, encoding=\"utf-8\")\n print(f\"index.html → {index_dst}\")\n\n\nif __name__ == \"__main__\":\n main()\n","content_type":"text/x-python; charset=utf-8","language":"python","size":5970,"content_sha256":"b62050fbbf64429e9ca1521f751346215cb0e8b6c9909a589002c11a6ede53b0"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"video-lens-gallery","type":"text"}]},{"type":"paragraph","content":[{"text":"Manage and browse your saved video-lens reports.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Step 1 — Locate skill scripts","type":"text"}]},{"type":"paragraph","content":[{"text":"Discover both ","type":"text"},{"text":"video-lens/scripts","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"$_sd","type":"text","marks":[{"type":"code_inline"}]},{"text":") and ","type":"text"},{"text":"video-lens-gallery/scripts","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"$_gd","type":"text","marks":[{"type":"code_inline"}]},{"text":") using the standard 8-agent discovery loop:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"_sd=$(for d in ~/.agents ~/.claude ~/.copilot ~/.gemini ~/.cursor ~/.windsurf ~/.opencode ~/.codex; do\n [ -d \"$d/skills/video-lens/scripts\" ] && echo \"$d/skills/video-lens/scripts\" && break\ndone)\n_gd=$(for d in ~/.agents ~/.claude ~/.copilot ~/.gemini ~/.cursor ~/.windsurf ~/.opencode ~/.codex; do\n [ -d \"$d/skills/video-lens-gallery/scripts\" ] && echo \"$d/skills/video-lens-gallery/scripts\" && break\ndone)\n[ -z \"$_sd\" ] && echo \"video-lens skill not found — install it first: npx skills add kar2phi/video-lens\" && exit 1\n[ -z \"$_gd\" ] && echo \"video-lens-gallery skill not found — install it first: npx skills add kar2phi/video-lens\" && exit 1","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Step 2 — Backfill metadata (only if requested)","type":"text"}]},{"type":"paragraph","content":[{"text":"If the user's request mentions \"backfill\", run:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"python3 \"$_gd/backfill_meta.py\" --dir ~/Downloads/video-lens","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Step 3 — Rebuild index","type":"text"}]},{"type":"paragraph","content":[{"text":"Check that the reports directory exists before running:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"[ -d ~/Downloads/video-lens ] || { echo \"No reports directory found — save some videos first with the video-lens skill.\"; exit 1; }\npython3 \"$_gd/build_index.py\" --dir ~/Downloads/video-lens","type":"text"}]},{"type":"paragraph","content":[{"text":"Tell the user the number of reports indexed, from the script's output.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Step 4 — Serve gallery","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"bash \"$_sd/serve_report.sh\" ~/Downloads/video-lens/index.html ~/Downloads/video-lens","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Tell the user the gallery is now available at ","type":"text"},{"text":"http://localhost:8765/index.html","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},"metadata":{"date":"2026-06-05","name":"video-lens-gallery","author":"@skillopedia","source":{"stars":70,"repo_name":"video-lens","origin_url":"https://github.com/kar2phi/video-lens/blob/HEAD/skills/video-lens-gallery/SKILL.md","repo_owner":"kar2phi","body_sha256":"295352c5071abb809dded6349c644be0ea775768507d05c38cbe2ef6b726bc9b","cluster_key":"077c74d35e85f33f42c5fc9aec070be2888eb4639caa18d3b164cb4b77a746e2","clean_bundle":{"format":"clean-skill-bundle-v1","source":"kar2phi/video-lens/skills/video-lens-gallery/SKILL.md","attachments":[{"id":"32659773-a596-524e-99a4-910c128da54e","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/32659773-a596-524e-99a4-910c128da54e/attachment.html","path":"index.html","size":44219,"sha256":"94cad4db979aaa68c0bc21bc768b3dc04b0ca77a9d3f404faec4b26671cfba0e","contentType":"text/html; charset=utf-8"},{"id":"74e36550-6199-587a-b153-89f8736ac588","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/74e36550-6199-587a-b153-89f8736ac588/attachment.py","path":"scripts/backfill_meta.py","size":7467,"sha256":"edbe01be419460315fe8b50b9bb6566999eae7189b921e6415ff2ead500a5f8f","contentType":"text/x-python; charset=utf-8"},{"id":"f9f6b1d2-b9a5-5567-a3b0-33875a347473","key":"uploads/10433ee7-ad12-4ae0-b34e-97553e46c6c8/f9f6b1d2-b9a5-5567-a3b0-33875a347473/attachment.py","path":"scripts/build_index.py","size":5970,"sha256":"b62050fbbf64429e9ca1521f751346215cb0e8b6c9909a589002c11a6ede53b0","contentType":"text/x-python; charset=utf-8"}],"bundle_sha256":"19c345ac633c54d804b4fdf4953edc00f6a333fa5e2fcb49b1b20d6c9e82455b","attachment_count":3,"text_attachments":3,"attachment_storage":"skillopedia-attachments-v1","binary_attachments":0,"excluded_attachments":[]},"cluster_size":1,"skill_md_path":"skills/video-lens-gallery/SKILL.md","import_metadata":{"date":"2026-06-05","author":"@skillopedia","version":"v1","category":"browser-automation-scraping","category_label":"Browser"},"exact_dupes_collapsed_into_this":0},"license":"MIT","version":"v1","category":"browser-automation-scraping","import_tag":"clean-skills-v1","description":"Open or rebuild the video-lens gallery index — your personal library of saved video summaries. Use this whenever the user wants to browse, open, or search saved video reports: \"show my gallery\", \"open video library\", \"browse saved videos\", \"build gallery\", \"what videos have I saved\", \"show my video notes\", \"my video summaries\", \"find my saved summary for [topic]\", \"rebuild the index\", \"show video-lens index\", \"backfill metadata\", \"update index\".\n","allowed-tools":"Bash"}},"renderedAt":1782981760625}

video-lens-gallery Manage and browse your saved video-lens reports. Step 1 — Locate skill scripts Discover both ( ) and ( ) using the standard 8-agent discovery loop: Step 2 — Backfill metadata (only if requested) If the user's request mentions "backfill", run: Step 3 — Rebuild index Check that the reports directory exists before running: Tell the user the number of reports indexed, from the script's output. Step 4 — Serve gallery Tell the user the gallery is now available at . ---