├── .gitignore ├── Dockerfile ├── README.md ├── _scratch.ipynb ├── assets ├── icon.png └── nbsanity.png ├── main.py ├── nb.yml ├── run_docker.sh └── static └── .gitignore /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | static/ 3 | *.ipynb 4 | !scratch.ipynb 5 | .ipynb_checkpoints 6 | temp/ 7 | tmp_notebooks/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/quarto-dev/quarto 2 | WORKDIR /app 3 | RUN apt-get update && apt-get install -y python3-pip 4 | RUN pip install fastapi[all] fastcore uvicorn requests rjsmin shot-scraper beautifulsoup4 nbformat 5 | RUN shot-scraper install 6 | RUN playwright install-deps 7 | COPY . . 8 | CMD uvicorn main:app --host 0.0.0.0 --port $PORT 9 | 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nbsanity 2 | 3 | Like nbviewer, but uses [Quarto](https://quarto.org/) as the renderer. 4 | 5 | ## Try It 6 | 7 | [https://nbsanity.com](https://nbsanity.com) 8 | 9 | ## Setup 10 | 11 | ### Local development 12 | 13 | First, install dependencies: 14 | 15 | ```bash 16 | pip install -U fastapi "uvicorn[standard]" fastcore 17 | ``` 18 | 19 | Also, [install Quarto](https://quarto.org/docs/get-started/). 20 | 21 | Then, run the app: 22 | 23 | ```bash 24 | uvicorn main:app 25 | ``` 26 | 27 | ### Docker 28 | 29 | Run the script [`run_docker.sh`](./run_docker.sh) to build and run the docker image. 30 | 31 | ## Usage 32 | 33 | You will see instructions on how to use the app on the home page. If running locally, you should substitute `nbsanity.com` with `localhost:` in the instructions. 34 | 35 | ## For Hamel 36 | 37 | To launch to [dokku](https://hamel.dev/blog/posts/dokku/), add the remote: 38 | 39 | ```bash 40 | git remote add prod dokku@nbsanity:q 41 | ``` 42 | 43 | Then, push to the remote: 44 | 45 | ```bash 46 | ssh dokku@nbsanity repo:purge-cache q 47 | git push prod 48 | ``` 49 | -------------------------------------------------------------------------------- /_scratch.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "3e5fb136", 6 | "metadata": {}, 7 | "source": [ 8 | "## Replace Title" 9 | ] 10 | }, 11 | { 12 | "cell_type": "markdown", 13 | "id": "3cb84602", 14 | "metadata": {}, 15 | "source": [ 16 | "##### Assistant" 17 | ] 18 | }, 19 | { 20 | "cell_type": "code", 21 | "execution_count": 1, 22 | "id": "b7b51a69", 23 | "metadata": {}, 24 | "outputs": [], 25 | "source": [ 26 | "from pathlib import Path\n", 27 | "from bs4 import BeautifulSoup as bs\n", 28 | "import requests\n", 29 | "from fastcore.net import urlsave\n", 30 | "from fastcore.utils import Path" 31 | ] 32 | }, 33 | { 34 | "cell_type": "markdown", 35 | "id": "9f74ac4c", 36 | "metadata": {}, 37 | "source": [ 38 | "##### User" 39 | ] 40 | }, 41 | { 42 | "cell_type": "code", 43 | "execution_count": 20, 44 | "id": "55ce0902", 45 | "metadata": {}, 46 | "outputs": [ 47 | { 48 | "name": "stdout", 49 | "output_type": "stream", 50 | "text": [ 51 | "cover.png long_tail.html long_tail.ipynb nb.yml\r\n" 52 | ] 53 | } 54 | ], 55 | "source": [ 56 | "!ls static/307cfee0a3f8f7d76b7646960ea599f0" 57 | ] 58 | }, 59 | { 60 | "cell_type": "code", 61 | "execution_count": 37, 62 | "id": "aa44a382", 63 | "metadata": {}, 64 | "outputs": [ 65 | { 66 | "name": "stdout", 67 | "output_type": "stream", 68 | "text": [ 69 | "\r\n", 70 | "\r\n", 71 | "\r\n", 72 | "\r\n", 73 | "\r\n", 74 | "\r\n", 75 | "Stitch Fix, Jupyter, GitHub, and the Long Tail\r\n", 76 | " 272 | 273 | 274 |

nbsanity: Render Notebooks On GitHub

275 |

Welcome to the Notebook Renderer! You can use this service in two ways:

276 | 277 |
278 |

Option 1: Bookmarklet

279 |

Drag this button to your bookmarks bar:

280 |

nbsanity

281 |

While viewing any .ipynb file on GitHub or Gist, click the bookmarklet to open it in nbsanity.

282 | 283 |
284 | 285 |
286 |

Option 2: Modify The URL

287 |

Modify any GitHub notebook URL to use nbsanity:

288 | 289 |
290 |

For Repository Notebooks:

291 |

Original: github.com/{NOTEBOOK_PATH}

292 |

Modified: nbsanity.com/{NOTEBOOK_PATH}

293 |
294 | 295 |
296 |

For Gists:

297 |

Original: gist.github.com/{GIST_PATH}

298 |

Modified: nbsanity.com/gist/{GIST_PATH}

299 |
300 |
301 |
302 |

Made by Hamel Husain

303 | 304 | 305 | ''' 306 | 307 | def update_meta(html_path: str|Path, 308 | image_path:str, 309 | title:str, 310 | default_title: str = "nbsanity | Jupyter Notebook Viewer"): 311 | """Update the meta tags in the HTML file.""" 312 | meta_tags=f""" 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | """.format(image_path=image_path, title=title) 333 | 334 | # Updated update checker JavaScript 335 | update_script = """ 336 | 392 | """ 393 | 394 | doc = Path(html_path) 395 | soup = bs(doc.read_text(encoding='utf-8'), 'html.parser') 396 | head = soup.find('head') 397 | 398 | # Add meta tags 399 | new_html = bs(meta_tags, 'html.parser') 400 | for element in new_html: head.insert(0, element) 401 | 402 | # Add update script 403 | script_tag = bs(update_script, 'html.parser') 404 | head.append(script_tag) 405 | doc.write_text(str(soup), encoding='utf-8') 406 | 407 | 408 | def generate_error_content(file_path, gist=False): 409 | """Generate HTML content for errors.""" 410 | back = f'Go back to GitHub' if not gist else f'Go back to Gist' 411 | return f""" 412 | 413 | Error 414 | 415 |

{file_path} is not a notebook.

416 | {back} 417 | 418 | 419 | """ 420 | 421 | def escape_filename(filename): 422 | "Replace HTML-like characters and other problematic characters" 423 | invalid_chars = '<>:"/\\|?*%#&{}+=`@!^;[]()$' 424 | for char in invalid_chars: 425 | filename = filename.replace(char, '_') 426 | return filename 427 | def process_nb_yml(notebook_path, full_url, hash_val): 428 | with open('nb.yml', 'r') as f: template = f.read() 429 | filled = template.replace('{{full_url}}', full_url).replace('{{image_path}}', f'https://nbsanity.com/static/{hash_val}/cover.png') 430 | output_path = os.path.join(notebook_path, 'nb.yml') 431 | with open(output_path, 'w') as f: f.write(filled) 432 | 433 | def get_title(html_path: str|Path, default_title: str = "nbsanity | Jupyter Notebook Viewer"): 434 | "Update the title in the HTML file." 435 | doc = Path(html_path) 436 | soup = bs(doc.read_text(encoding='utf-8'), 'html.parser') 437 | return soup.title.string if soup.title else default_title 438 | 439 | def fix_nb(nbpath): 440 | "Mutate notebook to right version for Quarto." 441 | nb = nbformat.read(nbpath, as_version=4) 442 | nbformat.write(nb, nbpath) 443 | 444 | def fix_image_paths(html_path: str|Path): 445 | """Fix image paths in HTML to handle missing images gracefully""" 446 | doc = Path(html_path) 447 | soup = bs(doc.read_text(encoding='utf-8'), 'html.parser') 448 | 449 | # Add onerror handler to all images 450 | for img in soup.find_all('img'): 451 | if 'onerror' not in img.attrs: 452 | img['onerror'] = "this.style.display='none'" 453 | 454 | doc.write_text(str(soup), encoding='utf-8') 455 | 456 | async def serve_notebook(file_path, gist=False): 457 | """Fetch, render, and serve the notebook.""" 458 | d = Path('tmp_notebooks') / str(uuid.uuid4()) 459 | d.mkdir(parents=True, exist_ok=True) 460 | full_url = 'https://github.com/' + file_path if not gist else 'https://gist.github.com/' + file_path 461 | try: 462 | nm = urlsave(git2raw(full_url), d) 463 | nm = nm.rename(nm.parent/escape_filename(nm.name)) 464 | 465 | hash_val = hashlib.md5(open(nm,'rb').read()).hexdigest() 466 | new_path = Path(f'static/{hash_val}') 467 | fix_nb(nm) 468 | # Load the notebook and modify non-compliant Quarto comments 469 | with open(nm, 'r') as f: 470 | notebook_data = json.load(f) 471 | for cell in notebook_data['cells']: 472 | if cell['cell_type'] == 'code': 473 | cell['source'] = escape_quarto_comments(cell['source']) 474 | 475 | # Save the modified notebook 476 | with open(nm, 'w') as f: 477 | json.dump(notebook_data, f) 478 | 479 | fname = nm.with_suffix('.html').name 480 | if not new_path.exists(): 481 | mkdir(new_path, exist_ok=True, overwrite=True) 482 | process_nb_yml(new_path, full_url, hash_val) 483 | run(f'quarto render "{nm}" --no-execute --to html --metadata-file {new_path}/nb.yml') 484 | shutil.copytree(d, str(new_path), dirs_exist_ok=True) 485 | title = get_title(f'{new_path}/{fname}') 486 | update_meta(f'{new_path}/{fname}', f'https://nbsanity.com/static/{hash_val}/cover.png', title) 487 | fix_image_paths(f'{new_path}/{fname}') 488 | run(f'shot-scraper "{new_path}/{fname}" -o {new_path}/cover.png -w 1200 -h 630') 489 | shutil.rmtree(d, ignore_errors=True) 490 | return RedirectResponse(f'/{new_path}/{fname}') 491 | 492 | except urllib.error.HTTPError as e: 493 | shutil.rmtree(new_path, ignore_errors=True) 494 | return handle_http_error(e, full_url) 495 | except Exception as e: 496 | shutil.rmtree(new_path, ignore_errors=True) 497 | raise e 498 | finally: shutil.rmtree(d, ignore_errors=True) 499 | 500 | def handle_http_error(e, full_url): 501 | """Handle HTTP errors during notebook fetch.""" 502 | if e.code == 404: 503 | return HTMLResponse(content=f'

Error: The notebook {full_url} was not found on GitHub. Please check the path and try again.

') 504 | return HTMLResponse(content=f'

An error occurred while fetching the notebook {full_url}: {e}

') 505 | 506 | def highlight_domain(text: str) -> str: 507 | """Highlights specific domains in the given text.""" 508 | domains = ['nbsanity.com', 'github.com'] 509 | for domain in domains: 510 | text = text.replace(domain, f'{domain}') 511 | return text 512 | 513 | def escape_quarto_comments(lines): 514 | """Escape comments that don't match Quarto's directive format.""" 515 | for idx, line in enumerate(lines): 516 | if line.strip().startswith('#|') and ':' not in line: 517 | lines[idx] = '#' + line 518 | return lines 519 | 520 | # Add before all other middleware 521 | app.add_middleware( 522 | CORSMiddleware, 523 | allow_origins=["*"], 524 | allow_methods=["GET"], # Only allow GET requests 525 | allow_headers=["*"], 526 | ) 527 | 528 | # Add these exception handlers after creating the FastAPI app 529 | @app.exception_handler(HTTPException) 530 | async def http_exception_handler(request, exc): 531 | return HTMLResponse( 532 | content=f""" 533 | 534 | Error {exc.status_code} 535 | 536 |

Error {exc.status_code}

537 |

{exc.detail}

538 |

Return to home

539 | 540 | 541 | """, 542 | status_code=exc.status_code 543 | ) 544 | 545 | @app.exception_handler(RequestValidationError) 546 | async def validation_exception_handler(request, exc): 547 | return HTMLResponse( 548 | content=f""" 549 | 550 | Invalid Request 551 | 552 |

Invalid Request

553 |

This URL doesn't support the request method or has invalid parameters.

554 |

Return to home

555 | 556 | 557 | """, 558 | status_code=405 559 | ) 560 | 561 | @app.route("/{path:path}", methods=["POST", "PUT", "DELETE", "PATCH", "OPTIONS"]) 562 | async def method_not_allowed(path: str): 563 | """Handle unsupported HTTP methods for any path""" 564 | return HTMLResponse( 565 | content=""" 566 | 567 | Method Not Allowed 568 | 569 |

Method Not Allowed

570 |

This service only supports GET requests.

571 |

Return to home

572 | 573 | 574 | """, 575 | status_code=405 576 | ) 577 | 578 | -------------------------------------------------------------------------------- /nb.yml: -------------------------------------------------------------------------------- 1 | format: 2 | html: 3 | code-overflow: wrap 4 | toc: true 5 | include-before-body: 6 | - text: | 7 |
8 |

Hosted with nbsanity. See source notebook on GitHub.

9 |
10 | -------------------------------------------------------------------------------- /run_docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Define the image name 4 | IMAGE_NAME="nbsanity" 5 | 6 | # Build the Docker image 7 | docker build -t $IMAGE_NAME . 8 | 9 | # Run the Docker container locally with a specified port for testing 10 | TEST_PORT=8000 11 | docker run -e PORT=$TEST_PORT -p $TEST_PORT:$TEST_PORT $IMAGE_NAME 12 | -------------------------------------------------------------------------------- /static/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamelsmu/nbsanity/596e771e041c0319b6757e87d90a724c4dc9194b/static/.gitignore --------------------------------------------------------------------------------