package bls // The OEWS occupation list drives the national wage expansion (M5 Phase 1). The BLS flat file at // download.bls.gov carries every OEWS occupation with a display level; display level 3 marks a DETAILED // SOC occupation (there are roughly 800). We keep only those, capturing the six digit SOC and the OEWS // occupation title, then build the two national series (annual mean 04, annual median 13) per SOC. // // The download requires a descriptive User Agent that names a contact email; BLS returns 403 to a default // or empty User Agent. The parser here is pure (no network, no database) so it is unit testable: it takes // the already fetched flat file bytes and returns the detailed occupations. import ( "bufio" "bytes" "context" "fmt" "io" "net/http" "strings" "time" ) // OccupationListURL is the BLS OEWS occupation flat file: tab delimited, one occupation per row. const OccupationListURL = "https://download.bls.gov/pub/time.series/oe/oe.occupation" // detailedDisplayLevel is the display level that marks a DETAILED SOC occupation in the OEWS flat file. // Aggregate roll up levels (0, 1, 2) are NOT detailed occupations and are skipped. const detailedDisplayLevel = "3" // DetailedOccupation is one detailed OEWS occupation: the six digit SOC code and the OEWS occupation // title. It carries no wage; the sync builds the national series from the SOC and pulls the wages. type DetailedOccupation struct { SOC string Title string } // ParseDetailedOccupations parses the tab delimited oe.occupation flat file and returns only the DETAILED // SOC occupations (display_level = 3), capturing the occupation_code (SOC) and occupation_name (title). // The columns are occupation_code, occupation_name, occupation_description, display_level, selectable, // sort_sequence. The header row (a row whose first column is the literal occupation_code) is skipped, as // is any short or blank row. The result is deterministic: the occupations come back in the file's order, // which the caller may re sort. It is pure: no network and no database. func ParseDetailedOccupations(r io.Reader) ([]DetailedOccupation, error) { var out []DetailedOccupation sc := bufio.NewScanner(r) // A flat file row can be long (the description column); raise the scanner buffer well past the default. sc.Buffer(make([]byte, 0, 1<<20), 1<<20) for sc.Scan() { line := sc.Text() if strings.TrimSpace(line) == "" { continue } cols := strings.Split(line, "\t") if len(cols) < 4 { continue // a malformed or truncated row cannot carry a display level; skip it } code := strings.TrimSpace(cols[0]) title := strings.TrimSpace(cols[1]) level := strings.TrimSpace(cols[3]) if code == "" || code == "occupation_code" { continue // the header row or a blank code } if level != detailedDisplayLevel { continue // an aggregate roll up level, not a detailed SOC } out = append(out, DetailedOccupation{SOC: code, Title: title}) } if err := sc.Err(); err != nil { return nil, fmt.Errorf("bls: scan occupation list: %w", err) } return out, nil } // FetchDetailedOccupations downloads the oe.occupation flat file with the required descriptive User Agent // and returns the detailed SOC occupations. userAgent MUST be a descriptive string that names a contact // email (for example "missionMATCH wage sync (joseph.w.anady@gmail.com)"); BLS returns 403 to a default // or empty User Agent, so an empty userAgent is rejected before the request is made. A non 200 response // returns a clear error carrying the status and a short body snippet, never the whole page. func FetchDetailedOccupations(ctx context.Context, httpClient *http.Client, url, userAgent string) ([]DetailedOccupation, error) { if strings.TrimSpace(userAgent) == "" { return nil, fmt.Errorf("bls: a descriptive User Agent naming a contact email is required for the BLS download") } if strings.TrimSpace(url) == "" { url = OccupationListURL } if httpClient == nil { httpClient = &http.Client{Timeout: 60 * time.Second} } req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("bls: build occupation list request: %w", err) } req.Header.Set("User-Agent", userAgent) resp, err := httpClient.Do(req) if err != nil { return nil, fmt.Errorf("bls: occupation list request failed: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return nil, fmt.Errorf("bls: occupation list status %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet))) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("bls: read occupation list: %w", err) } return ParseDetailedOccupations(bytes.NewReader(body)) }