Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP create product engineer job board #9943

Merged
merged 65 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
8c41418
created new job board page
jamesefhawkins Nov 21, 2024
3526c18
oops forgot to add all the other stuff
jamesefhawkins Nov 21, 2024
d823214
fixed stupid issue with gitignore format
jamesefhawkins Nov 21, 2024
0561287
reuse the card component from the homepage to display listings
jamesefhawkins Nov 21, 2024
074cae1
realized i shouldnt reuse that component
jamesefhawkins Nov 21, 2024
22f5281
only show engineering related jobs
jamesefhawkins Nov 21, 2024
48e95d2
ux improvements and added posthog as a second company
jamesefhawkins Nov 21, 2024
7936601
added more companies from the last HN who is hiring thread
jamesefhawkins Nov 21, 2024
01883d1
created filters based on what the companies are like and improved the…
jamesefhawkins Nov 21, 2024
53babe2
added more filters
jamesefhawkins Nov 21, 2024
dee1147
a bit of css improvement
jamesefhawkins Nov 21, 2024
39942fb
restore home page
smallbrownbike Nov 23, 2024
817549b
added automatic scraping to find more companies
jamesefhawkins Dec 2, 2024
60d4500
add job board page with company listings, perks, and department-based…
smallbrownbike Dec 4, 2024
ef9b6a3
filters
smallbrownbike Dec 5, 2024
d7a49d4
Merge remote-tracking branch 'refs/remotes/origin/job-board' into job…
smallbrownbike Dec 5, 2024
b8c9cb8
align
smallbrownbike Dec 5, 2024
3b76f93
remove jobs redirect
smallbrownbike Dec 5, 2024
2d7218b
sort and space
smallbrownbike Dec 5, 2024
6865ea4
space
smallbrownbike Dec 5, 2024
0d81563
space
smallbrownbike Dec 5, 2024
4029f24
added icons
corywatilo Dec 6, 2024
efb2753
fixes
smallbrownbike Dec 6, 2024
85a6659
NIIIIICE
smallbrownbike Dec 6, 2024
dd8e7e9
hasDeadlines -> noDeadlines
smallbrownbike Dec 6, 2024
b44a5f7
accordions
smallbrownbike Dec 6, 2024
033281f
scroll on filter change
smallbrownbike Dec 6, 2024
f1f063b
opacity
smallbrownbike Dec 6, 2024
d2d07c0
more scroll on filter change
smallbrownbike Dec 6, 2024
2a34a60
remove layout prop
smallbrownbike Dec 6, 2024
fbdcd79
use container queries
corywatilo Dec 7, 2024
9d22926
bulletproof responsiveness
corywatilo Dec 7, 2024
fc65544
show company description, more responsivness
corywatilo Dec 7, 2024
f63c138
move filters above jobs on mobile
corywatilo Dec 7, 2024
1c7292e
mobile expandable filter menu
corywatilo Dec 7, 2024
b29ce5e
link tweaks
corywatilo Dec 7, 2024
a48fef0
link to page
corywatilo Dec 7, 2024
04d8bf6
open graph image
corywatilo Dec 7, 2024
26502a6
how to apply
corywatilo Dec 7, 2024
2bed784
unique perks subheader
corywatilo Dec 7, 2024
cc72320
Merge branch 'master' into job-board
corywatilo Dec 7, 2024
a429a38
alphabetize teams
corywatilo Dec 7, 2024
6f721e9
added link from job applicant confirm screen
corywatilo Dec 7, 2024
1ca8838
footer link
corywatilo Dec 7, 2024
166e585
Create yarn.lock
corywatilo Dec 7, 2024
28244ab
padding adjustments
corywatilo Dec 7, 2024
a78ec0c
search
smallbrownbike Dec 8, 2024
c847ef9
dark mode
smallbrownbike Dec 8, 2024
9fac7f6
move and improve search
smallbrownbike Dec 8, 2024
ba8ae89
mobile
smallbrownbike Dec 8, 2024
7f56a46
add remote toggle / close departments by default / allow filter types…
smallbrownbike Jan 17, 2025
e70139b
initial open
smallbrownbike Jan 17, 2025
a0a0ca1
report issue form
smallbrownbike Jan 18, 2025
b6c14dc
add create company modal
smallbrownbike Jan 21, 2025
393382e
small clean
smallbrownbike Jan 21, 2025
bb1a408
support other job boards
smallbrownbike Jan 21, 2025
fbbbda1
edit/delete companies
smallbrownbike Jan 21, 2025
6646db1
add managing cool tech jobs docs
smallbrownbike Jan 21, 2025
437917d
hide companies without jobs when filtering as a mod
smallbrownbike Jan 21, 2025
f95f8f1
lil loading state
smallbrownbike Jan 21, 2025
2cc9cbd
doc clarification
smallbrownbike Jan 21, 2025
9cc7fb1
skele
smallbrownbike Jan 21, 2025
ddef66b
cleanup
corywatilo Jan 22, 2025
5a43cb3
Merge branch 'master' into job-board
corywatilo Jan 22, 2025
2912e6e
Update yarn.lock
corywatilo Jan 22, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ yarn.lock
src/components/Layout/Fonts.scss
.vercel
.env
*Type.ts
*Type.ts
.env.development
48 changes: 48 additions & 0 deletions contents/handbook/engineering/posthog-com/cool-tech-jobs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
title: Managing cool tech jobs
---

### Create a company/jobs:

- Login to PostHog.com as a moderator
- Navigate to `/cool-tech-jobs`
- Click “Add a company”
- Fill out the fields in the side modal
- Click Create company

When a company is created, its jobs are automatically scraped based on the job board URL/slug provided. If no jobs are found, the company doesn’t appear on `/cool-tech-jobs` (unless you’re a moderator, in which case it will appear semi-transparent).

### Edit a company

- Login to PostHog.com as a moderator
- Navigate to `/cool-tech-jobs`
- Click “Edit” under the desired company
- Fill out the fields in the side modal
- Click Update company

Jobs will be re-scraped when a company is edited.

### Delete a company

- Login to PostHog.com as a moderator
- Navigate to `/cool-tech-jobs`
- Click “Delete” under the desired company
- Confirm that you want to delete that company

All jobs associated with the deleted company will be deleted along with the original company record.

### Company fields

- **Company name**
- **Company website URL** - Used for the “Learn more” link
- **Job board type** (Ashby, Greenhouse, Other) - If “Other” is selected, a custom scraper will need to be built. When the company is published, it will be hidden as no jobs will be scraped.
- **Job board URL** (if Job board type is set to “Other”) - we’ll use this to build a custom scraper
- **Job board slug** (if Job board type is *not* “Other”) - the job board’s slug. This is automatically created as you type the company name, as these usually mirror each other. Must be unique and is checked for uniqueness as you type.
- **Company perks**
- **Company logos** - SVG/PNG only

Unless required conditionally (job board URL/slug), every company field is required.

### Scraping

Jobs are scraped hourly based on the provided job board URL/slug. Jobs are individually checked for freshness hourly. If a job URL 404s, it is deleted.
22 changes: 22 additions & 0 deletions gatsby-node.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import path from 'path'
import { GatsbyNode } from 'gatsby'
const axios = require('axios')

export { createPages } from './gatsby/createPages'
export { onCreateNode, onPreInit } from './gatsby/onCreateNode'
Expand Down Expand Up @@ -42,3 +43,24 @@ export const onCreateWebpackConfig: GatsbyNode['onCreateWebpackConfig'] = ({ sta
},
})
}

exports.createPages = async ({ actions }) => {
const { createPage } = actions

try {
const response = await axios.get('https://jobs.ashbyhq.com/supabase')
const jobData = JSON.parse(response.data)
const jobs = jobData?.jobBoard?.jobPostings || []

// Create the jobs page with the data
createPage({
path: '/jobs',
component: require.resolve('./src/templates/jobs.tsx'),
context: {
jobs: jobs,
},
})
} catch (error) {
console.error('Error fetching jobs:', error)
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@
"remove-markdown": "^0.5.5",
"request": "^2.88.2",
"sass": "^1.43.2",
"scrapingbee": "^1.7.5",
"slugify": "^1.6.0",
"styled-components": "^5.3.3",
"svg-sprite": "^1.5.0",
Expand Down
140 changes: 140 additions & 0 deletions src/api/scrape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import axios from 'axios'

// Helper function to get company logo URL
async function getCompanyLogo(company: string): Promise<string | null> {
try {
// Try to get the company's homepage
const response = await axios.get(`https://${company}.com`)
const baseUrl = `https://${company}.com`

// Try favicon patterns first
const faviconPatterns = [
/<link[^>]*rel="icon"[^>]*href="([^"]*)"/,
/<link[^>]*rel="shortcut icon"[^>]*href="([^"]*)"/,
/<link[^>]*rel="favicon"[^>]*href="([^"]*)"/,
]

// Helper function to resolve relative URLs
const resolveUrl = (path: string): string => {
if (path.startsWith('http')) return path
if (path.startsWith('//')) return 'https:' + path
if (path.startsWith('/')) return baseUrl + path
return baseUrl + '/' + path
}

// Try favicons
for (const pattern of faviconPatterns) {
const match = response.data.match(pattern)
if (match && match[1]) {
const faviconUrl = resolveUrl(match[1])
console.log(`Found favicon for ${company}:`, faviconUrl)

// Verify the favicon URL is accessible
try {
await axios.head(faviconUrl)
return faviconUrl
} catch (e) {
console.log(`Favicon not accessible for ${company}, trying next option`)
continue
}
}
}

// If no favicon found or accessible, try OpenGraph image
const ogImageMatch = response.data.match(/<meta[^>]*property="og:image"[^>]*content="([^"]*)"/)
if (ogImageMatch && ogImageMatch[1]) {
const ogImageUrl = resolveUrl(ogImageMatch[1])
console.log(`Found OG image for ${company}:`, ogImageUrl)

// Verify the OG image URL is accessible
try {
await axios.head(ogImageUrl)
return ogImageUrl
} catch (e) {
console.log(`OG image not accessible for ${company}, trying next option`)
}
}

// Try direct favicon.ico as last resort
const directFaviconUrl = `${baseUrl}/favicon.ico`
try {
await axios.head(directFaviconUrl)
console.log(`Found direct favicon.ico for ${company}:`, directFaviconUrl)
return directFaviconUrl
} catch (error) {
console.log(`No direct favicon.ico found for ${company}`)
}

console.log(`No logo found for ${company}`)
return null
} catch (error) {
console.error(`Error fetching logo for ${company}:`, error)
return null
}
}

/**
* @param {import('next').NextApiRequest} req
* @param {import('next').NextApiResponse} res
*/
export default async function scrape(req, res) {
if (req.method !== 'GET') {
res.setHeader('Allow', 'GET')
res.status(405).json({ success: false, message: 'Method not allowed' })
return
}

const company = req.query.company || 'supabase'

try {
// Get jobs data
const response = await axios.get(`https://jobs.ashbyhq.com/${company}`)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ashby has an api you can use, which just returns a JSON. Could be simpler:

  try {
    const response = await axios.post(
      'https://jobs.ashbyhq.com/api/non-user-graphql?op=ApiJobBoardWithTeams',
      {
        operationName: 'ApiJobBoardWithTeams',
        variables: { organizationHostedJobsPageName: ashbyURL.replace('https://jobs.ashbyhq.com/', '') },
        query: `query ApiJobBoardWithTeams($organizationHostedJobsPageName: String!) {
          jobBoard: jobBoardWithTeams(
            organizationHostedJobsPageName: $organizationHostedJobsPageName
          ) {
            teams {
              id
              name
              parentTeamId
              __typename
            }
            jobPostings {
              id
              title
              teamId
              locationId
              locationName
              employmentType
              secondaryLocations {
                ...JobPostingSecondaryLocationParts
                __typename
              }
              compensationTierSummary
              __typename
            }
            __typename
          }
        }
        
        fragment JobPostingSecondaryLocationParts on JobPostingSecondaryLocation {
          locationId
          locationName
          __typename
        }`,
      },
      {
        headers: {
          authority: 'jobs.ashbyhq.com',
          accept: '*/*',
          'accept-language': 'en-GB,en;q=0.9,nl;q=0.8',
          'apollographql-client-name': 'frontend_non_user',
          'apollographql-client-version': '0.1.0',
          'content-type': 'application/json',
          origin: 'https://jobs.ashbyhq.com',
          'user-agent': 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36',
        },
      },
    );

    console.log('Received response from Ashby API');
    data = response.data;
  } catch (error) {
    if (axios.isAxiosError(error) && error.response?.status === 404) {
      // uploadBadURL(ashbyURL, BadURLReason.CareersPage404);
    }
    console.log(`Error fetching ashby link: ${JSON.stringify(error)}`);
    return null;
  }

const match = response.data.match(/window\.__appData\s*=\s*({[^;]+})/)
if (!match) {
throw new Error(`Could not find job data in response for ${company}`)
}

const jobData = JSON.parse(match[1])
const jobs = jobData?.jobBoard?.jobPostings || []

// Try to get company logo
const logo = await getCompanyLogo(company)

const jobsWithLogo = jobs.map((job) => ({
...job,
company,
logo,
link: `https://jobs.ashbyhq.com/${company}/${job.id}`,
}))

res.status(200).json({
success: true,
jobs: jobsWithLogo,
})
} catch (error) {
console.error(`API Error for ${company}:`, error)
res.status(500).json({
success: false,
message: `Error scraping jobs for ${company}`,
details: error.message,
})
}
}

const ATS_PATTERNS = [
{
name: 'Greenhouse',
pattern: /greenhouse\.io/i,
boardPattern: /greenhouse\.io\/[^/]+$/i,
},
Comment on lines +125 to +129
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Greenhouse API:

  try {
    const response = await axios.get(
      `${greenhouseURL.replaceAll('https://boards.greenhouse.io/', 'https://boards-api.greenhouse.io/v1/boards/')}/jobs`,
    );
    console.log('Retrieved response from Greenhouse API');
    data = response.data;
  } catch (error) {
    if (axios.isAxiosError(error) && error.response?.status === 404) {
      // uploadBadURL(greenhouseURL, BadURLReason.CareersPage404);
    }
    console.log(`Error fetching greenhouse link: ${JSON.stringify(error)}`);
    return null;
  }

{
name: 'Lever',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lever API

  try {
    console.log(leverURL);
    const response = await axios.get(
      `${leverURL.replace('https://jobs.lever.co/', 'https://api.lever.co/v0/postings/')}?mode=json`,
    );
    console.log('Retrieved response from Lever API');
    data = response.data;
  } catch (error) {
    if (axios.isAxiosError(error) && error.response?.status === 404) {
      // uploadBadURL(leverURL, BadURLReason.CareersPage404);
    }
    console.log(`Error fetching lever link: ${JSON.stringify(error)}`);
    return null;
  }

pattern: /lever\.co/i,
boardPattern: /lever\.co\/[^/]+$/i,
},
{
name: 'Workday',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Workday is slightly trickier since there are so many jobs.

  try {
    // Modify the URL format
    const urlParts = myWorkDayURL.split('/');
    const subdomain = urlParts[2].split('.')[0]; // e.g., 'crowdstrike' or 'disney'
    const lastPath = urlParts[urlParts.length - 1];
    const modifiedURL = `${myWorkDayURL.replace(`.com/${lastPath}`, `.com/wday/cxs/${subdomain}/${lastPath}/jobs`)}`;
    while (totalJobs === null || allJobs.length < totalJobs) {
      const response = await axios.post(
        modifiedURL,
        {
          appliedFacets: {},
          limit,
          offset,
          searchText: '',
        },
        {
          headers: {
            accept: 'application/json',
            'accept-language': 'en-US',
            'content-type': 'application/json',
            origin: new URL(myWorkDayURL).origin,
            priority: 'u=1, i',
            referer: myWorkDayURL,
            'sec-ch-ua': '"Google Chrome";v="129", "Not=A?Brand";v="8", "Chromium";v="129"',
            'sec-ch-ua-mobile': '?0',
            'sec-ch-ua-platform': '"macOS"',
            'sec-fetch-dest': 'empty',
            'sec-fetch-mode': 'cors',
            'sec-fetch-site': 'same-origin',
            'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36',
            'x-calypso-csrf-token': '1d23cde2-8ccb-4b22-baef-42bff17d22bf',
          },
        },
      );

pattern: /myworkdayjobs\.com/i,
boardPattern: /myworkdayjobs\.com\/[^/]+$/i,
},
]
49 changes: 49 additions & 0 deletions src/api/test-ashby.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { findNewAshbyJobs, addNewCompaniesToConfig } from '../components/Jobs/jobsAshby'

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const logs: string[] = []

// Capture console.log output
const originalLog = console.log
console.log = (...args) => {
logs.push(args.map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : arg)).join(' '))
originalLog.apply(console, args)
}

try {
const companies = await findNewAshbyJobs()
const addedCompanies = await addNewCompaniesToConfig()

// Restore original console.log
console.log = originalLog

res.status(200).json({
debug: {
nodeEnv: process.env.NODE_ENV,
hasApiKey: !!process.env.GATSBY_SCRAPING_BEE_API_KEY,
apiKeyFirstChars: process.env.GATSBY_SCRAPING_BEE_API_KEY
? process.env.GATSBY_SCRAPING_BEE_API_KEY.substring(0, 4) + '...'
: 'none',
logs: logs,
},
companiesFound: companies,
companiesAdded: addedCompanies,
})
} catch (error) {
// Restore original console.log
console.log = originalLog

res.status(500).json({
error: 'Failed to fetch companies',
details: error.message,
debug: {
nodeEnv: process.env.NODE_ENV,
hasApiKey: !!process.env.GATSBY_SCRAPING_BEE_API_KEY,
logs: logs,
errorMessage: error.message,
errorStack: error.stack,
},
})
}
}
8 changes: 7 additions & 1 deletion src/components/AskMax/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ interface AskMaxProps {
children?: React.ReactNode
}

export default function AskMax({ border = false, className = '', quickQuestions, linkOnly = false, children }: AskMaxProps) {
export default function AskMax({
border = false,
className = '',
quickQuestions,
linkOnly = false,
children,
}: AskMaxProps) {
const posthog = usePostHog()
const { compact } = useLayoutData()
const { openChat, setQuickQuestions } = useChat()
Expand Down
59 changes: 35 additions & 24 deletions src/components/Docs/Intro.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,40 @@ import React from 'react'
import { CallToAction } from '../CallToAction'
import CloudinaryImage from '../CloudinaryImage'

export default function Intro({ subheader, title, description, buttonText, buttonLink, imageColumnClasses, imageUrl, imageClasses}) {
return (
<div className="bg-accent dark:bg-accent-dark border border-light dark:border-dark rounded flex flex-col items-center md:flex-row md:gap-4 mb-8">
<div className="p-4 pb-0 md:p-8 flex-1 w-full">
<p className="text-[15px] text-primary/60 dark:text-primary-dark/75 mb-1">{subheader}</p>
<h1 className="text-3xl md:text-4xl mt-0 mb-1 md:mb-2">{title}</h1>
<h3 className="text-base md:text-lg font-semibold text-primary/60 dark:text-primary-dark/75 !leading-tight"> {description}</h3>
<CallToAction to={buttonLink}>{buttonText}</CallToAction>
</div>
export default function Intro({
subheader,
title,
description,
buttonText,
buttonLink,
imageColumnClasses,
imageUrl,
imageClasses,
}) {
return (
<div className="bg-accent dark:bg-accent-dark border border-light dark:border-dark rounded flex flex-col items-center md:flex-row md:gap-4 mb-8">
<div className="p-4 pb-0 md:p-8 flex-1 w-full">
<p className="text-[15px] text-primary/60 dark:text-primary-dark/75 mb-1">{subheader}</p>
<h1 className="text-3xl md:text-4xl mt-0 mb-1 md:mb-2">{title}</h1>
<h3 className="text-base md:text-lg font-semibold text-primary/60 dark:text-primary-dark/75 !leading-tight">
{' '}
{description}
</h3>
<CallToAction to={buttonLink}>{buttonText}</CallToAction>
</div>

{imageUrl && (
<figure className="m-0 mt-auto p-0 md:pr-8 flex items-end">
<CloudinaryImage
alt=""
placeholder="none"
quality={100}
className={imageColumnClasses}
imgClassName={imageClasses}
src={imageUrl}
/>
</figure>
)}

</div>
)
{imageUrl && (
<figure className="m-0 mt-auto p-0 md:pr-8 flex items-end">
<CloudinaryImage
alt=""
placeholder="none"
quality={100}
className={imageColumnClasses}
imgClassName={imageClasses}
src={imageUrl}
/>
</figure>
)}
</div>
)
}
8 changes: 4 additions & 4 deletions src/components/Docs/ResourceItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ export default function ResourceItem({ title, description, Image, gatsbyImage, u
</h6>
)}
<h4 className="m-0 text-lg leading-tight text-primary dark:text-primary-dark">{title}</h4>
<p className="text-primary/60 dark:text-primary-dark/60 text-[15px] mt-1 mb-0 !leading-tight">{description}</p>
<p className="text-primary/60 dark:text-primary-dark/60 text-[15px] mt-1 mb-0 !leading-tight">
{description}
</p>
</div>
{Image && (
<div className="flex justify-end w-full h-24">
<div className="w-48 h-24 md:absolute bottom-0">
{Image}
</div>
<div className="w-48 h-24 md:absolute bottom-0">{Image}</div>
</div>
)}
</Link>
Expand Down
4 changes: 4 additions & 0 deletions src/components/Footer/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ const linklist: IProps[] = [
title: 'PostHog on GitHub',
url: 'https://github.com/PostHog/posthog',
},
{
title: 'Cool tech jobs',
url: '/cool-tech-jobs',
},
],
},
{
Expand Down
Loading
Loading