Matt Morris

Vibe scripting with AI and Python

Authors
  • avatar
    Name
    Matt Morris
    LinkedIn

One of the greatest benefits of AI is its ability to help you write code, which doesn't just include large applications. It can also help you write scripts for smaller tasks, a great way for someone new to programming to get started and create something useful quickly.

In this post, I share a few examples of how I use AI to help me write Python scripts for a variety of purposes that could be approachable for anyone willing to take a few steps to set up a development environment and install any necessary packages (AI can help with that as well).

These are the kinds of capabilities that can be used on their own, extended to agents, or included in an automated workflow.

Examples

Caption file generator script

I work with video a lot and we always need caption files for the videos we produce. These are generally available but sometimes not (or they are available in a different format than we need).

Previously this would have required finding a team that can convert the file or scrambling to find a tool that can do it for free (usually with AdWare). Now, it's incredibly easy to use AI to generate a script for you that can create a caption file in whatever format you need. You just need a Python development environment set up and the necessary packages installed.

The script uses the OpenAI Whisper model model to transcribe the audio from a video file and output it as a caption file in the desired format.

You run it from the command line, passing in the video file as an argument, and it will generate a caption file in the same directory.

import whisper
import sys
import os

def write_srt(segments, output_path):
    with open(output_path, "w", encoding="utf-8") as f:
        for i, seg in enumerate(segments, start=1):
            start = seg["start"]
            end = seg["end"]
            text = seg["text"].strip()

            def ts(t):
                hrs = int(t // 3600)
                mins = int((t % 3600) // 60)
                secs = int(t % 60)
                ms = int((t - int(t)) * 1000)
                return f"{hrs:02}:{mins:02}:{secs:02},{ms:03}"

            f.write(f"{i}\n")
            f.write(f"{ts(start)} --> {ts(end)}\n")
            f.write(f"{text}\n\n")

def main():
    if len(sys.argv) < 2:
        print("Usage: python mp4_to_srt.py input.mp4")
        sys.exit(1)

    input_file = sys.argv[1]
    output_file = os.path.splitext(input_file)[0] + ".srt"

    print("Loading Whisper model…")
    model = whisper.load_model("medium")  # or "small", "large", etc.

    print("Transcribing…")
    result = model.transcribe(input_file)

    print("Writing SRT…")
    write_srt(result["segments"], output_file)

    print(f"Done! SRT saved to {output_file}")

if __name__ == "__main__":
    main()

Example Usage

I used the viral "I'm not a cat" video as the input for this example:

Here's the script output in SRT format:


1
00:00:00,000 --> 00:00:10,779
Mr. Ponton, I believe you have a filter turned on in the video settings.

2
00:00:10,779 --> 00:00:12,880
You might want to take a look.

3
00:00:12,880 --> 00:00:16,640
We're trying to, can you hear me, Judge?

4
00:00:16,640 --> 00:00:17,679
I can hear you.

5
00:00:17,679 --> 00:00:19,679
I think it's a filter.

6
00:00:19,679 --> 00:00:22,440
It is and I don't know how to remove it.

7
00:00:22,440 --> 00:00:23,719
I've got my assistant here.

8
00:00:23,719 --> 00:00:29,400
She's trying to, but I'm prepared to go forward with it.

9
00:00:29,959 --> 00:00:30,759
I'm here live.

10
00:00:30,759 --> 00:00:32,159
That's not, I'm not a cat.

11
00:00:35,679 --> 00:00:37,519
I can, I can see that.

12
00:00:37,960 --> 00:00:42,119
I think if you click the up arrow next to the.

Web scraper script

I work with web data a lot and sometimes need to scrape data from websites. This can be a tedious task, especially when the website is dynamic and requires interaction to load all the data.

This example uses Playwright to scrape the Apple Retail Success Stories page. It connects to a running instance of Chrome with remote debugging enabled, navigates to the page, scrolls to load all the content, and extracts the relevant information.

from playwright.sync_api import sync_playwright, Error as PlaywrightError
from urllib.parse import urljoin
import sys
import time

BASE_URL = "https://www.apple.com/business/enterprise/success-stories/retail/"
CDP_URL = "http://127.0.0.1:9223"

def scrape_retail():
    with sync_playwright() as p:
        # Connect to the already-running real Chrome instance
        print("Connecting to Chrome debugging instance on port 9223...")
        try:
            browser = p.chromium.connect_over_cdp(CDP_URL)
        except PlaywrightError as e:
            if "ECONNREFUSED" in str(e):
                print(
                    "ERROR: Could not reach Chrome's debugging port at "
                    f"{CDP_URL}. Chrome isn't running with remote debugging enabled.\n"
                    "Close all Chrome windows, then start it with:\n\n"
                    '  & "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" '
                    '--remote-debugging-port=9223 --user-data-dir="C:\\chrome-debug-profile"\n'
                )
                sys.exit(1)
            raise

        # Diagnostic: show contexts returned by Chrome
        print("Connected contexts:", browser.contexts)

        # If no contexts exist, Chrome debugging is NOT being used
        if not browser.contexts:
            print("ERROR: No contexts found. Playwright did NOT attach to Chrome.")
            return []

        context = browser.contexts[0]

        page = context.new_page()

        page.goto(BASE_URL, wait_until="domcontentloaded")
        page.wait_for_selector(".tile", timeout=20000)

        # Scroll slowly to trigger lazy loading + hydration
        for _ in range(25):
            page.mouse.wheel(0, 600)
            time.sleep(0.3)

        # Give React time to finish hydration
        page.wait_for_timeout(2000)

        tiles = page.query_selector_all(".tile")
        print("Tile elements found (includes hidden duplicates):", len(tiles))

        stories_by_url = {}

        for tile in tiles:
            link = tile.query_selector("a")
            if not link:
                continue

            href = link.get_attribute("href")
            if not href:
                continue
            full_url = urljoin(BASE_URL, href)

            title_el = tile.query_selector("h3")
            if title_el:
                title = title_el.inner_text().strip()
            else:
                credit_el = tile.query_selector(".quote-credit")
                title = credit_el.inner_text().strip() if credit_el else None

            desc_el = tile.query_selector("blockquote p")
            desc = desc_el.inner_text().strip() if desc_el else None

            image = None
            img_el = tile.query_selector("img")
            if img_el:
                image = img_el.get_attribute("src") or img_el.get_attribute("data-src")
            else:
                icon_el = tile.query_selector(".image-icon")
                if icon_el:
                    bg = icon_el.evaluate("el => getComputedStyle(el).backgroundImage")
                    if bg and bg.startswith("url("):
                        image = bg[4:-1].strip("'\"")

            if image:
                image = urljoin(BASE_URL, image)

            stories_by_url[full_url] = {
                "title": title,
                "description": desc,
                "url": full_url,
                "image": image
            }

        print("Unique stories after dedup:", len(stories_by_url))

        page.close()
        return list(stories_by_url.values())


if __name__ == "__main__":
    data = scrape_retail()
    for story in data:
        print(story)

Example output

You could adapt this to output to a spreadsheet or whatever format you need to, but for this example, I just printed the results to the console. The output is a list of dictionaries, each containing the title, description, URL, and image (if available) of each unique story found on the page.

The script can also be adapted to scrape other pages or websites by changing the BASE_URL and adjusting the selectors as needed.

Semantic search script

Python is excellent at processing text data, and AI can help you do it even better. One of the most useful applications of AI is semantic search, which allows you to search for information based on meaning rather than just keywords.

The script I generated uses the sentence-transformers library to create a semantic search mini-engine that can index documents and perform semantic queries against them. The script can load documents from a folder, extract text from various file formats (plain text, markdown, HTML, Word, PDF, PowerPoint, Excel), and build embeddings for the documents using a transformer model. It also provides a fallback option using TF-IDF and SVD if the transformer model is not available.

"""
Semantic Search Mini-Engine
============================

Loads documents and lets you run semantic queries against them from an
interactive CLI. By default it loads the placeholder docs in data/ (see
generate_data.py + data/manifest.json), but you can point it at any folder:

    python3 semantic_search.py --dir "/path/to/other/folder"

If that folder has its own manifest.json (id/filename/title/category/date, like
data/manifest.json), it's used for metadata. Otherwise the script walks the
folder recursively and indexes every file it can extract text from — plain
text/markdown/csv/html plus Word (.docx), PDF (.pdf), PowerPoint (.pptx), and
Excel (.xlsx). Old binary Office formats (.doc/.ppt/.xls) aren't supported.

Embedding backend:
  - Preferred: sentence-transformers ("all-MiniLM-L6-v2"), a real local transformer
    embedding model. Used automatically if the package is installed and the model
    can be downloaded (first run only; it's cached under ~/.cache afterwards).
  - Fallback: TF-IDF + Truncated SVD (a.k.a. Latent Semantic Analysis). Pure
    scikit-learn, no model download required, so it always works offline. The
    engine picks this automatically if sentence-transformers isn't available.

Usage:
    python3 semantic_search.py [--dir FOLDER] [--ext .txt,.md,.docx,...] [--top-k 5]

Requirements: see requirements.txt
"""

import argparse
import json
import os
import re
import sys
from html.parser import HTMLParser

import numpy as np

DEFAULT_DIR = "data"

# Folders that are never worth walking into.
SKIP_DIR_NAMES = {
    ".git", ".svn", ".hg", "__pycache__", "node_modules", ".venv", "venv",
    "$RECYCLE.BIN", "System Volume Information",
}

# Skip cloud-sync/backup dirs and anything hidden (dotfolders).
def _should_skip_dir(name):
    return name in SKIP_DIR_NAMES or name.startswith(".")


MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024  # skip anything bigger than this

TOP_K_DEFAULT = 5


class _HTMLTextExtractor(HTMLParser):
    def __init__(self):
        super().__init__()
        self.chunks = []

    def handle_data(self, data):
        self.chunks.append(data)

    def text(self):
        return " ".join(self.chunks)


def _extract_plain(path):
    with open(path, encoding="utf-8", errors="ignore") as f:
        return f.read()


def _extract_html(path):
    raw = _extract_plain(path)
    parser = _HTMLTextExtractor()
    parser.feed(raw)
    return re.sub(r"\s+", " ", parser.text())


def _extract_docx(path):
    import docx

    d = docx.Document(path)
    parts = [p.text for p in d.paragraphs]
    for table in d.tables:
        for row in table.rows:
            parts.append(" | ".join(cell.text for cell in row.cells))
    return "\n".join(parts)


def _extract_pdf(path):
    from pypdf import PdfReader

    reader = PdfReader(path)
    return "\n".join((page.extract_text() or "") for page in reader.pages)


def _extract_pptx(path):
    from pptx import Presentation

    prs = Presentation(path)
    parts = []
    for slide in prs.slides:
        for shape in slide.shapes:
            if shape.has_text_frame:
                parts.append(shape.text_frame.text)
            if shape.has_table:
                for row in shape.table.rows:
                    parts.append(" | ".join(cell.text for cell in row.cells))
    return "\n".join(parts)


def _extract_xlsx(path):
    import openpyxl

    wb = openpyxl.load_workbook(path, data_only=True, read_only=True)
    parts = []
    for ws in wb.worksheets:
        for row in ws.iter_rows(values_only=True):
            cells = [str(c) for c in row if c is not None]
            if cells:
                parts.append(" | ".join(cells))
    return "\n".join(parts)


EXTRACTORS = {
    ".txt": _extract_plain,
    ".md": _extract_plain,
    ".csv": _extract_plain,
    ".log": _extract_plain,
    ".json": _extract_plain,
    ".html": _extract_html,
    ".htm": _extract_html,
    ".docx": _extract_docx,
    ".pdf": _extract_pdf,
    ".pptx": _extract_pptx,
    ".xlsx": _extract_xlsx,
}

DEFAULT_EXTENSIONS = tuple(EXTRACTORS.keys())


def load_documents(root_dir, extensions=DEFAULT_EXTENSIONS, verbose=True):
    """Load metadata + text content for every document under root_dir.

    Uses root_dir/manifest.json if present (same format as data/manifest.json).
    Otherwise walks root_dir recursively and indexes every file matching
    `extensions`, extracting text with the format-appropriate extractor.
    """
    if not os.path.isdir(root_dir):
        sys.exit(f"'{root_dir}' isn't a folder I can find.")

    manifest_path = os.path.join(root_dir, "manifest.json")
    if os.path.exists(manifest_path):
        with open(manifest_path) as f:
            manifest = json.load(f)

        docs = []
        for entry in manifest:
            path = os.path.join(root_dir, entry["filename"])
            docs.append({**entry, "text": _extract_plain(path)})
        return docs

    # No manifest — walk the folder and extract text from matching files.
    docs = []
    doc_id = 1
    skipped = []  # (rel_path, reason)

    for dirpath, dirnames, filenames in os.walk(root_dir):
        dirnames[:] = [d for d in dirnames if not _should_skip_dir(d)]

        for filename in sorted(filenames):
            ext = os.path.splitext(filename)[1].lower()
            if ext not in extensions:
                continue

            path = os.path.join(dirpath, filename)
            rel_path = os.path.relpath(path, root_dir)

            try:
                if os.path.getsize(path) > MAX_FILE_SIZE_BYTES:
                    skipped.append((rel_path, "over size limit"))
                    continue
            except OSError as e:
                skipped.append((rel_path, f"can't stat file ({e.__class__.__name__})"))
                continue

            extractor = EXTRACTORS.get(ext, _extract_plain)
            try:
                text = extractor(path)
            except Exception as e:
                skipped.append((rel_path, f"{e.__class__.__name__}: {e}"))
                continue

            if not text or not text.strip():
                skipped.append((rel_path, "no extractable text (empty/scanned/image-only)"))
                continue

            docs.append(
                {
                    "id": doc_id,
                    "filename": rel_path,
                    "title": os.path.splitext(filename)[0],
                    "category": os.path.dirname(rel_path) or "uncategorized",
                    "date": "",
                    "text": text,
                }
            )
            doc_id += 1

    if verbose and skipped:
        print(f"(skipped {len(skipped)} files)")
        for rel_path, reason in skipped[:30]:
            print(f"    - {rel_path}: {reason}")
        if len(skipped) > 30:
            print(f"    ...and {len(skipped) - 30} more")

    if not docs:
        sys.exit(
            f"No files with a supported extension found under '{root_dir}'. "
            f"Supported: {', '.join(sorted(EXTRACTORS))}. Pass --ext to narrow/widen the list."
        )
    return docs


class EmbeddingBackend:
    """Turns a list of strings into vectors. Tries sentence-transformers first,
    falls back to TF-IDF + SVD (LSA) if that's unavailable or can't reach the
    internet to download its model."""

    def __init__(self):
        self.name = None
        self._model = None
        self._vectorizer = None
        self._svd = None
        self._init_backend()

    def _init_backend(self):
        try:
            from sentence_transformers import SentenceTransformer

            self._model = SentenceTransformer("all-MiniLM-L6-v2")
            self.name = "sentence-transformers/all-MiniLM-L6-v2"
            return
        except Exception:
            pass  # not installed, or couldn't download the model — fall back

        self.name = "tfidf-svd (fallback, no model download required)"

    def fit_transform(self, texts):
        """Fit the backend on the corpus and return document vectors."""
        if self._model is not None:
            return self._model.encode(texts, normalize_embeddings=True)

        from sklearn.feature_extraction.text import TfidfVectorizer

        # max_df/min_df filtering only makes sense with a reasonably sized
        # corpus — with very few documents they can conflict (e.g. max_df's
        # document count works out to less than min_df's) and scikit-learn
        # raises. Relax them below a threshold instead of crashing.
        if len(texts) >= 5:
            vectorizer_kwargs = {"max_df": 0.9}
        else:
            vectorizer_kwargs = {}

        self._vectorizer = TfidfVectorizer(stop_words="english", **vectorizer_kwargs)
        tfidf = self._vectorizer.fit_transform(texts)

        max_components = min(tfidf.shape[0], tfidf.shape[1]) - 1
        if max_components < 2:
            # Too little data/vocabulary for a meaningful SVD — search directly
            # on the (normalized) TF-IDF vectors instead.
            self._svd = None
            return self._normalize(tfidf.toarray())

        from sklearn.decomposition import TruncatedSVD

        n_components = min(100, max_components)
        self._svd = TruncatedSVD(n_components=n_components, random_state=42)
        vectors = self._svd.fit_transform(tfidf)
        return self._normalize(vectors)

    def transform(self, texts):
        """Embed new text (e.g. a query) using the already-fitted backend."""
        if self._model is not None:
            return self._model.encode(texts, normalize_embeddings=True)

        tfidf = self._vectorizer.transform(texts)
        if self._svd is None:
            return self._normalize(tfidf.toarray())
        vectors = self._svd.transform(tfidf)
        return self._normalize(vectors)

    @staticmethod
    def _normalize(vectors):
        vectors = np.asarray(vectors, dtype=np.float32)
        norms = np.linalg.norm(vectors, axis=1, keepdims=True)
        norms[norms == 0] = 1.0
        return vectors / norms


class SemanticSearchEngine:
    def __init__(self, docs):
        self.docs = docs
        self.backend = EmbeddingBackend()
        texts = [d["text"] for d in docs]
        self.doc_vectors = self.backend.fit_transform(texts)

    def search(self, query, top_k=TOP_K_DEFAULT):
        query_vector = self.backend.transform([query])[0]
        scores = self.doc_vectors @ query_vector
        ranked_idx = np.argsort(-scores)[:top_k]
        return [(self.docs[i], float(scores[i])) for i in ranked_idx]


def snippet(text, length=140):
    flat = " ".join(text.split())
    return flat if len(flat) <= length else flat[:length].rstrip() + "..."


def print_results(results):
    if not results:
        print("  No results.")
        return
    for rank, (doc, score) in enumerate(results, start=1):
        meta = doc["category"]
        if doc.get("date"):
            meta += f", {doc['date']}"
        print(f"  {rank}. [{score:.3f}] {doc['title']}  ({meta})  — {doc['filename']}")
        print(f"     {snippet(doc['text'])}")


def parse_args():
    parser = argparse.ArgumentParser(description="Semantic Search Mini-Engine")
    parser.add_argument(
        "--dir",
        default=DEFAULT_DIR,
        help=f"Folder to search (default: {DEFAULT_DIR})",
    )
    parser.add_argument(
        "--ext",
        default=",".join(DEFAULT_EXTENSIONS),
        help="Comma-separated file extensions to index when no manifest.json "
        f"is present (default: all supported types — {','.join(DEFAULT_EXTENSIONS)})",
    )
    parser.add_argument(
        "--top-k",
        type=int,
        default=TOP_K_DEFAULT,
        help=f"Number of results to show per query (default: {TOP_K_DEFAULT})",
    )
    return parser.parse_args()


def run_cli():
    args = parse_args()
    extensions = tuple(e.strip().lower() for e in args.ext.split(","))

    docs = load_documents(args.dir, extensions)
    print(f"Loaded {len(docs)} documents from {args.dir}/")

    engine = SemanticSearchEngine(docs)
    print(f"Embedding backend: {engine.backend.name}")

    print('\nType a query and press Enter. Type "quit" or "exit" to leave.\n')
    while True:
        try:
            query = input("Search> ").strip()
        except (EOFError, KeyboardInterrupt):
            print()
            break

        if not query:
            continue
        if query.lower() in {"quit", "exit"}:
            break

        results = engine.search(query, top_k=args.top_k)
        print_results(results)
        print()


if __name__ == "__main__":
    run_cli()


Example output

I used AI to create some placeholder documents and then ran the semantic search script against them. Here's an example of what the output looks like when you run a query: