Skip to content

grahamzemel/gingersnaps

Repository files navigation

Nick Lathrop — Photography portfolio

Personal portfolio for Nick Lathrop, a photographer based in Golden, Colorado. Auto-syncs from Instagram every 24 hours via GitHub Actions.

Stack: Astro 5 · Svelte islands · Tailwind v4 · Lenis smooth scroll · GSAP ScrollTrigger · PhotoSwipe lightbox · Sharp + thumbhash · Playwright stealth scraper · Cloudflare R2 · Netlify

Quick start

git clone <repo>
cd nicklathrop-photography
npm install
npm run dev          # http://localhost:4321

The site builds with placeholder seed photos so you can iterate without running the scraper. First scrape replaces the seed data.

Architecture

GitHub Actions cron (24h) ─→ Playwright stealth ─→ images downloaded
                                                     │
              ┌─ R2 (full-res, lightbox) ←───────────┤
              │                                      │
              └─ public/thumbs/ (800px webp) ←───────┤
                 src/data/posts.json (metadata)  ←───┘
                 │
                 git push  ─→  Netlify build hook  ─→  rebuild + deploy

Setup

1. Cloudflare R2 (full-res image storage)

R2 holds the original-resolution photos (lightbox). The repo only carries 800px thumbs.

  1. Create a Cloudflare account, enable R2.
  2. Create a bucket — e.g. nicklathrop-photos.
  3. R2 → Manage R2 API Tokens → "Create Token" with Object Read & Write on the bucket. Save Access Key ID and Secret Access Key.
  4. Bucket → Settings → Public access → connect a custom subdomain (e.g. photos.nicklathrop.com) or use the r2.dev URL.
  5. Note the account ID (top of the R2 dashboard URL).

2. Burner Instagram session

The scraper logs in as a throwaway IG account. Do not use Nick's real account.

  1. Create a fresh Instagram account (no profile photo, no bio, just an email).
  2. Have it follow @ginger.snaps19 + 5–10 random photography accounts so it looks real.
  3. Log in via an incognito browser window.
  4. DevTools → Application → Cookies → https://www.instagram.com
  5. Copy three cookie values: sessionid, ds_user_id, csrftoken.
  6. Paste into the IG_SESSION_COOKIE GitHub Actions secret as JSON:
{"sessionid":"...","ds_user_id":"...","csrftoken":"..."}

If IG ever flags the burner, repeat with a new account. Real account stays untouched.

3. Netlify

  1. Run ./scripts/deploy-site.sh once. Requires netlify-cli (npm i -g netlify-cli && netlify login) and gh (gh auth login).
  2. Script creates the site, links it to GitHub, creates a build hook, saves the hook URL as the NETLIFY_BUILD_HOOK GitHub Actions secret.
  3. Site goes live at https://<random>.netlify.app.

4. GitHub Actions secrets

In Settings → Secrets and variables → Actions, add:

Secret Source
IG_SESSION_COOKIE Step 2
R2_ACCOUNT_ID Step 1
R2_ACCESS_KEY Step 1
R2_SECRET_KEY Step 1
R2_BUCKET Step 1
R2_PUBLIC_HOST Step 1 (e.g. https://photos.nicklathrop.com)
NETLIFY_BUILD_HOOK Step 3 (set automatically by deploy-site.sh)

5. Trigger first scrape

Actions tab → "Scrape Instagram" → "Run workflow" → leave dry=false → Run. Watch the run; it should commit src/data/posts.json + public/thumbs/*.webp and trigger a Netlify build.

Custom domain (do this when ready)

The plan ships v1 on a Netlify subdomain. The Google ranking timeline against LinkedIn starts when the custom domain goes live, not when v1 ships. Run this as soon as Nick has a domain:

./scripts/swap-domain.sh nicklathrop.com

This adds the domain to Netlify, provisions Let's Encrypt, updates the canonical URL in astro.config.mjs, adds a 301 redirect from the old subdomain, commits and pushes, and prints a post-swap checklist.

Off-page SEO checklist (for Nick)

The site has on-page SEO covered (titles, schema, sitemap, RSS, content depth). What ranking against LinkedIn actually needs from you:

  • Buy nicklathrop.com (or .photography / nicklathropphoto.com). Cloudflare Registrar is at-cost. Single highest-leverage thing.
  • Run swap-domain.sh once you've got the domain.
  • Submit https://nicklathrop.com/sitemap-index.xml to Google Search Console.
  • Same to Bing Webmaster Tools.
  • Add https://nicklathrop.com to your LinkedIn → Contact info → Website. This tells Google the two profiles are the same person and consolidates authority on your site.
  • Update your Instagram bio link to the new domain.
  • Set up a Google Business Profile as "Photographer in Golden, CO". Single biggest local-SEO lift.
  • Cross-post to Behance / 500px / Flickr with nicklathrop.com in the profile bio. Each is a high-DA backlink.
  • Pitch a guest gallery to a local Denver/Golden community blog or magazine — even one good editorial backlink kills LinkedIn's authority advantage.
  • List on photography directories (Local Photographers Network, The Knot if doing weddings, Snappr, Photographer Directory).
  • Mark your calendar for 90 days post domain swap: revisit ranking. Typical timeline to outrank LinkedIn for personal name = 3–6 months.

Local development

npm run dev               # dev server at http://localhost:4321
npm run build             # static build to dist/
npm run preview           # serve the static build
npm run scrape            # run the IG scraper locally (needs IG_SESSION_COOKIE in .env)
npm run scrape:dry        # scrape but don't commit anything
npm run thumbs            # regenerate thumbnails from tmp/scrape/
npm run verify            # screenshot audit + Playwright sanity check (writes verify/*.png)

Editing posts manually

src/data/posts.json is auto-written by the scraper, but four fields are preserved across runs by ID match:

  • alt — accessibility text (defaults to caption)
  • category — one of portrait | landscape | street | lifestyle | other
  • featured — boolean, drops the photo into the hero/featured grid
  • note — manually written ~50-word note that shows up on the post detail page (this is what makes individual posts substantive enough to rank)

Edit the JSON, commit, push. Scraper picks up your edits on the next run.

Risk notes

  • Scraping Instagram violates their ToS. Burner account isolates the risk to a throwaway. If IG fingerprints it, swap in a new burner — the site stays online from the last good posts.json.
  • IG CDN URLs expire ~24h. The scraper always downloads, never hotlinks.
  • Stealth Playwright is not invincible. Plan B is the Behold.so widget (TODO: wire up if scraper fails 2+ times in a row); Plan C is just the existing static posts.json cache. The site never goes blank.

File map

.github/workflows/
  scrape-instagram.yml          # cron + manual dispatch
scripts/
  scrape.mjs                    # Playwright stealth → tmp/scrape/
  make-thumbs.mjs               # Sharp → public/thumbs/, thumbhash → manifest
  upload-r2.mjs                 # full-res → Cloudflare R2
  verify-screenshots.mjs        # Playwright section-by-section audit
  deploy-site.sh                # Netlify ↔ GitHub auto-link (one-shot)
  swap-domain.sh                # custom domain swap + 301s + GSC checklist
src/
  pages/
    index.astro                 # hero + featured + latest IG + CTA
    work/index.astro            # full archive with category filter
    work/[slug].astro           # individual post detail w/ EXIF + Image schema
    about.astro                 # ~400-word bio for SEO depth
    denver-photographer.astro   # local landing page targeting "Denver photographer" queries
    contact.astro               # Text-CTA + sidebar
    404.astro
    rss.xml.ts                  # RSS feed for SEO freshness signal
  components/
    Hero.astro, Nav.astro, Footer.astro, Schema.astro, ContactSection.astro
    Reveal.svelte               # IntersectionObserver-driven fade-up (forwards `class`)
    Masonry.svelte, Lightbox.svelte, ScrollPin.svelte, Cursor.svelte, SmoothScroll.svelte
  styles/
    tokens.css                  # OKLCH design tokens
    app.css                     # @keyframes + base
  data/posts.json               # authoritative source of truth (scraper writes, you edit)
  lib/                          # site config, SEO helpers, posts loader
public/
  thumbs/                       # 800px webp, git-tracked
  robots.txt, _redirects, favicon.svg
astro.config.mjs, tailwind.config.ts, netlify.toml, tsconfig.json

License

MIT — see LICENSE. Photographs are © Nick Lathrop and not covered by the code license.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors