Download main.py from crystaljhollis/smu-keyword-optimizer: direct link, hf CLI and curl.
- Browser
- Download file 24.7 kB
-
https://huggingface.co/spaces/crystaljhollis/smu-keyword-optimizer/resolve/99efcd077624cf552fffef009f765137ea6a5e84/main.py
- Command line
-
hf download hf://spaces/crystaljhollis/smu-keyword-optimizer@99efcd077624cf552fffef009f765137ea6a5e84/main.py
-
curl -L -o main.py https://huggingface.co/spaces/crystaljhollis/smu-keyword-optimizer/resolve/99efcd077624cf552fffef009f765137ea6a5e84/main.py
24.7 kB
| # Main.py by Crystal J. Hollis | crystaljhollis@gmail.com | |
| # GitHub: https://github.com/crystaljhollis | |
| # 08/2025 | |
| # Developed using Python 3.13.7 | |
| # | |
| # ======================================================================================================================================= | |
| # LIBRARIES | |
| # ======================================================================================================================================= | |
| # Import Python Standard Libraries | |
| import re #regular expressions aka regex | |
| from pathlib import Path #file system paths | |
| from typing import List, Tuple, Set #gradual typing support | |
| # Import gradio | |
| import gradio as gr # Documentation: https://www.gradio.app/ | |
| # ======================================================================================================================================= | |
| # FUNCTIONS | |
| # ======================================================================================================================================= | |
| # HELPER FUNCTIONS | |
| _SLUG_OK = re.compile(r"[^a-z0-9_]+") | |
| def slugify(text: str) -> str: | |
| """lowercase, spaces->underscore, drop junk""" | |
| s = text.strip().lower() | |
| s = s.replace("&", " and ") | |
| s = re.sub(r"[^\w\s-]", "", s) # keep letters/numbers/underscore/hyphen/space | |
| s = re.sub(r"\s+", "_", s) # collapse spaces to _ | |
| s = _SLUG_OK.sub("_", s) # anything else -> _ | |
| s = re.sub(r"_+", "_", s).strip("_") # collapse repeats | |
| return s | |
| def suggest_slugs(locked_people: List[str], locked_locs: List[str], rough_terms: List[str]) -> List[str]: | |
| """ | |
| Very simple slug ideas using location + common event words. | |
| Swap this for an LLM later if you want. | |
| """ | |
| loc = locked_locs[0] if locked_locs else "" | |
| base = slugify(loc) if loc else "gallery_item" | |
| # pick a few common event-ish tags if present | |
| vocab = ["opening ceremony", "ribbon cutting", "audience", "ceremony", "speeches", "panel", "performance"] | |
| hits = [v for v in vocab if any(v in t.lower() for t in rough_terms)] | |
| slugs = [f"{base}_{slugify(h)}" for h in hits[:3]] or [base] | |
| # add a couple safe fallbacks | |
| return list(dict.fromkeys(slugs + [f"{base}_event", f"{base}_scene"])) | |
| def suggest_headline_title(locked_locs: List[str], rough_terms: List[str]) -> Tuple[str, str]: | |
| """ | |
| Headline = longer, Title = shorter. Heuristics only. | |
| """ | |
| loc = locked_locs[0] if locked_locs else "Campus" | |
| if any("ribbon" in t.lower() and "cut" in t.lower() for t in rough_terms): | |
| headline = f"{loc} Opening Ceremony and Ribbon Cutting at SMU" | |
| title = f"{loc} Opening Ceremony" | |
| else: | |
| headline = f"Event at {loc}, Southern Methodist University" | |
| title = f"{loc} Event" | |
| return headline, title | |
| def suggest_description(locked_locs: List[str], rough_terms: List[str]) -> str: | |
| """ | |
| Template now; replace with Gemma call later. | |
| """ | |
| loc = locked_locs[0] if locked_locs else "campus venue" | |
| phrases = [t for t in rough_terms if len(t.split()) <= 4][:6] | |
| core = ", ".join(phrases) if phrases else "program footage, speeches, audience" | |
| return ( | |
| f"Program footage from the {loc} event at Southern Methodist University, featuring {core} " | |
| f"in Dallas, Texas." | |
| ) | |
| def people_variants_stub(people_list: List[str]) -> str: | |
| """ | |
| Placeholder until Gemma is wired. Produces generic roles. | |
| """ | |
| if not people_list: | |
| return "" | |
| base = [] | |
| # naive role guessers | |
| hints = ["president", "dean", "trustee", "donor", "student", "faculty"] | |
| base.extend({h for h in hints}) | |
| return ", ".join(sorted(set(base))) | |
| # SPLIT TERMS FUNCTION == accepts text and returns a cleaned list | |
| def _split_terms(text: str) -> List[str]: | |
| """ | |
| Accepts comma/newline/semicolon separated text and returns a cleaned list. | |
| - trims spaces | |
| - collapses internal whitespace | |
| - removes surrounding quotes | |
| - keeps hyphens and apostrophes (useful for names/places) | |
| """ | |
| if not text: | |
| return [] # Empty text returns empty list | |
| parts = re.split(r"[,\n;]+", text) # regex split on commas, semicolons, or newlines | |
| cleaned = [] # Prepare list to hold cleaned-up terms | |
| for p in parts: # Iterates through each text piece | |
| t = p.strip().strip('"').strip("'") # removes trailing spaces, double/single quotes | |
| # collapse extra internal spaces | |
| t = re.sub(r"\s+", " ", t) # regex sub whitespace w/single space (\s+) | |
| if t: | |
| cleaned.append(t) # If t is not empty, append to the cleaned list | |
| return cleaned # Returns the cleaned list | |
| # LOAD MASTER KEYWORDS FUNCTION == user uploads txt file | |
| def _load_master_keywords(file_obj) -> Set[str]: # Accepts .txt, returns lowercase keywords | |
| """ | |
| Load hierarchical SMU keyword file. | |
| - Keeps each line as its own entry | |
| - Ignores bracketed category headers like [Academic Life] | |
| - Strips tabs/spaces | |
| """ | |
| if not file_obj: | |
| return set() | |
| if isinstance(file_obj, bytes): | |
| content = file_obj.decode("utf-8", errors="ignore") # Decode file_obj into string with UTF-8 encoding, ignore weird characters | |
| else: | |
| content = str(file_obj) #force to string | |
| keywords = set() | |
| for line in content.splitlines(): # Iterates line by line | |
| line = line.strip() | |
| if not line: | |
| continue # Ignores blank lines | |
| # skip category headers in [brackets] | |
| if line.startswith("[") and line.endswith("]"): | |
| continue # Ignores [Category] headers | |
| keywords.add(line.lower()) # Preserve keywords, matching lowercase | |
| return keywords # Returns the cleaned set of keywords | |
| # COMMA+SPACE FUNCTION == simply adds comma and space for each keyword | |
| def _to_csv_line(items: List[str]) -> str: # Adds comma and space to cleaned list | |
| """ | |
| Make a comma+space separated line | |
| for copy-paste. | |
| """ | |
| return ", ".join(items) # Concatenates all elements separated by ", " | |
| # KEYWORD CHECKER FUNCTION | |
| def keyword_checker( # Takes arguments: | |
| job_number: str, # Job Number i.e. 26-999 | |
| neg_number: str, # Neg Number | |
| rough_keywords: str, # Rough draft keywords | |
| people_locked: str, # Names of Persons Shown | |
| locations_locked: str, # Names of Locations | |
| names_locked: str, # Other locked terms | |
| master_txt # Latest keyword txt file | |
| ): | |
| # [1] Parse inputs; Converts raw text into a list, calling _split_terms | |
| rough_list = _split_terms(rough_keywords) | |
| people_list = _split_terms(people_locked) | |
| locations_list = _split_terms(locations_locked) | |
| names_list = _split_terms(names_locked) | |
| # [2] Build locked sets (lowercase for matching) | |
| locked_map = { # Dictionary | |
| "PEOPLE": set(p.lower() for p in people_list), # lowercase so matching is case-insensitive | |
| "LOCATIONS": set(l.lower() for l in locations_list), | |
| "LOCKED": set(a.lower() for a in names_list), | |
| } | |
| locked_all = set().union(*locked_map.values()) # Combines everything in the dictionary, keeping only unique elements | |
| # [3] Load master keyword list | |
| master_set = _load_master_keywords(master_txt) if master_txt else set() # Load keyword txt if provided, else empty set | |
| # [4] Categorize | |
| locked_hits = {"PEOPLE": [], "LOCATIONS": [], "LOCKED": []} # locked terms separated by category | |
| in_master = [] # from keyword txt file | |
| new_terms = [] # not in keyword txt file | |
| for kw in rough_list: | |
| k = kw.lower() # loop through each keywords, make it lowercase | |
| # Check locked first (so they can be handled specially later) | |
| hit_locked = False # checked if keyword is in a locked category | |
| for cat, s in locked_map.items(): | |
| if k in s: | |
| locked_hits[cat].append(kw) # If yes, add to locked_hits | |
| hit_locked = True | |
| break | |
| if hit_locked: # locked terms don't need to be checked against keyword txt file | |
| continue | |
| # Then check against master | |
| if master_set: # check if in the keyword txt file | |
| if k in master_set: | |
| in_master.append(kw) # If yes, in_master | |
| else: | |
| new_terms.append(kw) # If no, new_terms | |
| else: | |
| # No master list provided | |
| in_master.append(kw) # If no keyword txt file, treat all non-locked terms as in_master | |
| # [5] Build human-readable outputs | |
| locked_block = { | |
| "PEOPLE": _to_csv_line(locked_hits["PEOPLE"]), | |
| "LOCATIONS": _to_csv_line(locked_hits["LOCATIONS"]), | |
| "LOCKED": _to_csv_line(locked_hits["LOCKED"]), | |
| } | |
| in_list_block = _to_csv_line(in_master) | |
| new_block = _to_csv_line(new_terms) | |
| combined_csv = _to_csv_line(locked_hits["PEOPLE"] + locked_hits["LOCATIONS"] + locked_hits["LOCKED"] + in_master + new_terms) # Combined summary output | |
| # [6] LLM customizable fields | |
| slugs = suggest_slugs(locked_hits["PEOPLE"], locked_hits["LOCATIONS"], rough_list) | |
| headline, title = suggest_headline_title(locked_hits["LOCATIONS"], rough_list) | |
| description = suggest_description(locked_hits["LOCATIONS"], rough_list) | |
| people_variants = people_variants_stub(locked_hits["PEOPLE"]) # replace w/ Gemma later | |
| # [7] Summary line | |
| summary = ( | |
| f"Job {job_number or '(none)'} | " # Job Number | |
| f"Catalog {neg_number or '(none)'} | " # Neg Number | |
| f"Total input: {len(rough_list)} | " # Total Keywords Checked | |
| f"Locked: {sum(len(v) for v in locked_hits.values())} | " # Total Locked Hits Category | |
| f"In List: {len(in_master)} | " # Total from keyword txt file | |
| f"New: {len(new_terms)}" # Total new keywords | |
| ) | |
| # [8] Return in same order as UI outputs | |
| return ( | |
| job_number, # Job Number (echo) | |
| neg_number, # Neg Number (echo) | |
| "\n".join(slugs), # Recommended filename slugs | |
| headline, # Headline | |
| title, # Title | |
| description, # Description / Alt Text | |
| locked_block["PEOPLE"], # PEOPLE (Persons Shown) | |
| people_variants, # PEOPLE VARIANTS (suggested / LLM) | |
| locked_block["LOCATIONS"], # LOCKED: LOCATIONS | |
| locked_block["LOCKED"], # LOCKED: OTHER | |
| in_list_block, # BATCH + SEO | |
| new_block, # OPTIONAL + SEO | |
| combined_csv, # All Combined CSV | |
| "", # Credit Line Field (optional inputs) | |
| "", # Creator Field (optional inputs) | |
| summary, # (optional) Summary – nice to have last | |
| ) | |
| # ======================================================================================================================================= | |
| # GRADIO GUI CONFIG | |
| # ======================================================================================================================================= | |
| # Theme Object | |
| custom = gr.themes.Base( | |
| primary_hue="red", # Red main accent color family | |
| neutral_hue="zinc", # Neutral palette | |
| ).set( # Override CSS | |
| # Page text | |
| body_text_color="#1c1c1c", # black text | |
| body_text_color_subdued="#666666", # subdued text dark gray | |
| body_text_size="14px", # Font size | |
| body_text_weight="400", # regular weight font | |
| # Page & blocks | |
| background_fill_primary="#ffffff", # white cards | |
| background_fill_secondary="#ffffff", # dark mode, white cards | |
| block_background_fill="#eeeeee", # light gray background | |
| block_border_color="#d6d6d6", # light gray border | |
| block_title_text_color="#1c1c1c", # black text block titles | |
| # Inputs/buttons | |
| input_background_fill="#ffffff", # white input boxes | |
| input_border_color="#d6d6d6", # light gray border | |
| button_primary_background_fill="#ce191c", # red primary buttons | |
| button_primary_background_fill_hover="#7b2325", # dark red on hover | |
| button_primary_text_color="#ffffff", # white text on red button | |
| button_secondary_background_fill="#343434", # dark mode, very dark gray | |
| button_secondary_background_fill_hover="#2b2b2b", # dark mode, darker gray on hover | |
| button_secondary_text_color="#ffffff", # dark mode, white text | |
| # Links + radius/shadow | |
| link_text_color="#1c1c1c", # link text is black | |
| embed_radius="8px", # all cards/blocks have slightly rounded corners | |
| border_color_accent="#d6d6d6", # light gray accent borders | |
| shadow_drop="0 4px 16px rgba(0,0,0,0.08)", # subtle shadow effect around cards | |
| ) | |
| CSS = ''' | |
| /* ====== Palette - unused at the moment, save for later ====== */ | |
| :root{ | |
| --smu-dark-gray: #666666; | |
| --smu-very-dark: #343434; | |
| --smu-light-gray: #d6d6d6; | |
| --smu-off-white: #eeeeee; | |
| --smu-white: #ffffff; | |
| --smu-black: #1c1c1c; | |
| --smu-red: #8e2a2c; | |
| --smu-salmon: #ff756d; /* reserved for "suggested keywords" – not used yet */ | |
| } | |
| /* ====== Hard-lock app to light mode ====== */ | |
| html, :root, body, .gradio-container{ | |
| color-scheme: light !important; /* browsers render controls as light */ | |
| background: var(--smu-off-white) !important; /* page background */ | |
| color: var(--smu-black) !important; | |
| } | |
| /* ====== Global text ====== */ | |
| h1, h2, h3, label, p, .gr-markdown *{ | |
| color: var(--smu-black) !important; | |
| } | |
| /* ====== Panels / cards ====== */ | |
| .gr-block{ | |
| background: var(--smu-white) !important; | |
| border: 1px solid var(--smu-light-gray) !important; | |
| border-radius: 8px !important; | |
| } | |
| /* ====== Tabs / top bars (FileMaker-ish chrome) ====== */ | |
| .gr-tabs, .gr-panel{ background: var(--smu-dark-gray) !important; } | |
| .gr-tabs *, .gr-panel *{ color: var(--smu-white) !important; } | |
| /* ====== Inputs ====== */ | |
| .gradio-container input[type="text"], | |
| .gradio-container textarea{ | |
| background: var(--smu-white) !important; | |
| color: var(--smu-black) !important; | |
| border-color: var(--smu-light-gray) !important; | |
| } | |
| .gradio-container input[type="text"]::placeholder, | |
| .gradio-container textarea::placeholder{ | |
| color: var(--smu-dark-gray) !important; | |
| opacity: 1 !important; | |
| } | |
| /* ====== Buttons ====== */ | |
| button.gr-button-primary{ | |
| background: var(--smu-red) !important; color: #fff !important; | |
| } | |
| button.gr-button-primary:hover{ filter: brightness(.92); } | |
| button.gr-button-secondary{ | |
| background: var(--smu-very-dark) !important; color: #fff !important; | |
| } | |
| button.gr-button-secondary:hover{ filter: brightness(1.05); } | |
| /* ====== Copy blocks (make borders more visible) ====== */ | |
| /* Add elem_classes=["copyblock"] to those Textboxes you want highlighted */ | |
| .copyblock textarea{ | |
| border: 2px solid var(--smu-dark-gray) !important; | |
| box-shadow: inset 0 1px 0 rgba(0,0,0,.03); | |
| } | |
| /* ====== Upload zones / dividers ====== */ | |
| #upload_panel, #upload_group{ background: var(--smu-white) !important; border-radius: 8px; } | |
| input[type="file"], .gr-file{ border-color: var(--smu-light-gray) !important; } | |
| hr{ border-color: var(--smu-light-gray) !important; } | |
| /* ====== Reserved style for future "suggested keywords" (not applied yet) ====== */ | |
| /* Later you can add elem_classes=["suggested"] to a Textbox or container */ | |
| .suggested textarea, .suggested{ | |
| /* NOT enabled yet; uncomment when wiring the feature: | |
| background: #ff756d1a !important; /* subtle salmon tint */ | |
| border-color: var(--smu-salmon) !important; | |
| */ | |
| } | |
| ''' | |
| # ======================================================================================================================================= | |
| # GRADIO APP | |
| # ======================================================================================================================================= | |
| with gr.Blocks(title="Job Sheet Keyword and Metadata SEO", theme=custom) as demo: # css=CSS is not being used at the moment! # gr.Blocks is a container laying out multiple UI elements | |
| gr.Markdown( # gr.Markdown block to explain to the user what the app does | |
| "# Job Sheet Keyword and Metadata SEO \n" | |
| "Paste rough draft keywords, add optional **locked terms** (PEOPLE / LOCATIONS / NAMES), " | |
| "and upload the **latest keyword file** (.txt)." | |
| ) | |
| with gr.Row(): # gr.Row horizontal components | |
| job_number = gr.Textbox(label="Job Number", placeholder="i.e. 26-999", scale=1) # Job Number Textbox | |
| neg_number = gr.Textbox(label="Catalog / Neg Number", placeholder="i.e. 12345D", scale=1) # Neg Number Textbox | |
| rough = gr.Textbox( # Rough Draft Keywords Textbox | |
| label="Rough Draft Keywords (comma / newline / semicolon separated)", | |
| lines=6, | |
| placeholder="i.e. Owen Arts Center, Meadows School of the Arts, SMU, opening ceremony, ribbon cutting, Dallas, Texas" | |
| ) | |
| with gr.Accordion("Locked Terms (will not generate SEO suggestions / synonyms)", open=False, elem_id="locked_panel"): # Dropdown Section Locked Terms | |
| with gr.Row(): | |
| people = gr.Textbox(label="PEOPLE (Persons Shown)", lines=4, placeholder="i.e. R. Gerald Turner, Samuel S. Holland") # Persons Shown Textbox | |
| locations = gr.Textbox(label="SUBLOCATION or ADDRESS / CITY / STATE / COUNTRY", lines=4, placeholder="i.e. Owen Arts Center, Dallas, Texas, United States") # Locations Textbox | |
| names = gr.Textbox(label="OTHER LOCKED TERMS", lines=4, placeholder="i.e. Meadows School of the Arts, Lyle School of Engineering") # Locked Terms Textbox | |
| with gr.Row(): | |
| credit_in = gr.Textbox(label="Credit Line (optional input)", placeholder="Southern Methodist University / John Doe", scale=1) | |
| creator_in = gr.Textbox(label="Creator (optional input)", placeholder="John Doe", scale=1) | |
| master = gr.File(label="Upload the latest keyword list (.txt Keyword file; newline or comma separated)", # Upload dialog | |
| file_types=[".txt"], elem_id="upload_panel") | |
| run = gr.Button("Run Keyword Optimizer", variant="primary") # Run Red Button | |
| # ---- OUTPUTS (order must match function returns) ---- | |
| job_out = gr.Textbox(label="Job Number", interactive=False, lines=1, elem_classes=["copyblock"]) | |
| neg_out = gr.Textbox(label="Neg Number (Job Identifier Field)", interactive=False, lines=1, elem_classes=["copyblock"]) | |
| slugs_out = gr.Textbox(label="Recommended filename slugs (suggested)", interactive=False, lines=4, elem_classes=["copyblock"]) | |
| headline_out = gr.Textbox(label="Headline", interactive=False, lines=2, elem_classes=["copyblock"]) | |
| title_out = gr.Textbox(label="Title", interactive=False, lines=2, elem_classes=["copyblock"]) | |
| desc_out = gr.Textbox(label="Description / Alt Text", interactive=False, lines=4, elem_classes=["copyblock"]) | |
| people_out = gr.Textbox(label="PEOPLE (Persons Shown Field)", interactive=False, lines=3, elem_classes=["copyblock"]) | |
| peoplevar_out = gr.Textbox(label="PEOPLE VARIANTS (suggested)", interactive=False, lines=2, elem_classes=["copyblock"]) | |
| loc_out = gr.Textbox(label="LOCKED: LOCATIONS (copy block)", interactive=False, lines=2, elem_classes=["copyblock"]) | |
| locked_out = gr.Textbox(label="LOCKED TERMS (no synonyms)", interactive=False, lines=2, elem_classes=["copyblock"]) | |
| in_list_out = gr.Textbox(label="BATCH + SEO (suggested)", interactive=False, lines=4, elem_classes=["copyblock"]) | |
| new_out = gr.Textbox(label="OPTIONAL + SEO (new)", interactive=False, lines=4, elem_classes=["copyblock"]) | |
| csv_out = gr.Textbox(label="All Combined (CSV)", interactive=False, lines=3, elem_classes=["copyblock"]) | |
| credit_out = gr.Textbox(label="Credit Line Field", interactive=False, lines=1, elem_classes=["copyblock"]) | |
| creator_out = gr.Textbox(label="Creator Field", interactive=False, lines=1, elem_classes=["copyblock"]) | |
| summary_out = gr.Textbox(label="Summary (debug)", interactive=False, lines=2) | |
| # Click wiring – note input list includes credit/creator, pass them into function if you want to use them | |
| run.click( | |
| fn=keyword_checker, | |
| inputs=[job_number, neg_number, rough, people, locations, names, master], | |
| outputs=[job_out, neg_out, slugs_out, headline_out, title_out, desc_out, | |
| people_out, peoplevar_out, loc_out, locked_out, | |
| in_list_out, new_out, csv_out, credit_out, creator_out, summary_out] | |
| ) | |
| #demo.launch(share=True) # launches the app, switch to false to turn off sharing | |
| if __name__ == "__main__": | |
| demo.launch() # local runs only | |