For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a Ruby-based data-ingest pipeline that surfaces 1,500-5,000 long-tail Rails-using companies across ten verticals, classifies them via Sonnet subagents, and produces a scorecard for Rick to pick one niche to target.
Architecture: Ruby scrapers write to a single SQLite database (data/rails_longtail.sqlite). Each scraper is a thin wrapper over a focused source (GitHub API, public HTML pages, or RSS/API feeds) that upserts companies and appends signals. Classification and exploratory web search are orchestrated through Claude Code (Sonnet subagents via the Agent tool); their outputs are JSONL files consumed by a merge script. SQL views drive the scorecard. Phases 0-2 of the spec are in scope; Phase 3 (deep dive in the chosen niche) is out of scope and becomes a follow-up plan after the niche is picked.
Tech Stack: Ruby 3.2+, Bundler, SQLite3, Nokogiri (HTML), HTTParty (REST), Feedjira (RSS), Minitest (tests), VCR + WebMock (HTTP stubbing), rake (task runner). Classification orchestrated via Claude Code Agent tool (no Anthropic SDK dependency in Ruby code).
Scope: This plan covers spec Sections 1-9 (Phases 0-2 only). Spec Section 8 (Phase 3 Deep Dive) is a human-manual process gated on Phase 2’s niche pick; a thin runbook template is included as Task 15 but the detailed vertical-specific sources are deferred to a follow-up plan.
Project layout (created in Task 1):
tooling/rails_longtail/
Gemfile
Rakefile
schema.sql
README.md
bin/
bootstrap_db
ingest_github
ingest_hn_hiring
ingest_job_feeds
ingest_board_dorks
ingest_rails_foundation
ingest_conferences
ingest_gems
merge_classifications
compute_long_tail_fit
flag_audit
run_scorecard
export_csvs
lib/
rails_longtail.rb -- top-level requires
rails_longtail/
db.rb -- SQLite connection + schema loader
company.rb -- upsert_by_domain
signal.rb -- insert_signal
source_run.rb -- start/finish run logging
github.rb -- GitHub REST + code-search client
hn_hiring.rb -- HN Algolia search
job_feeds.rb -- WWR RSS + RemoteOK JSON
board_dorks.rb -- DDG results for site:greenhouse.io etc.
rails_foundation.rb -- scrape member/sponsor list
conferences.rb -- scrape sponsor pages
gems.rb -- rubygems + github org lookup
classification.rb -- consume subagent JSONL → classifications table
long_tail_fit.rb
audit.rb
scorecard.rb
test/
test_helper.rb
db_test.rb
company_test.rb
signal_test.rb
github_test.rb
hn_hiring_test.rb
... -- one per lib/ file
fixtures/ -- VCR cassettes + sample HTML
docs/
vertical_definitions.md
business_model_taxonomy.md
classification_runbook.md
classification_prompt.md
web_search_runbook.md
phase3_runbook.md
data/.keep -- rails_longtail.sqlite lives here, gitignored
exports/.keep -- CSV exports, gitignored
.gitignore
Files:
tooling/rails_longtail/Gemfiletooling/rails_longtail/Rakefiletooling/rails_longtail/.gitignoretooling/rails_longtail/test/test_helper.rbtooling/rails_longtail/lib/rails_longtail.rbCreate: tooling/rails_longtail/data/.keep, tooling/rails_longtail/exports/.keep
Run from repo root:
mkdir -p tooling/rails_longtail/{bin,lib/rails_longtail,test/fixtures,docs,data,exports}
touch tooling/rails_longtail/data/.keep tooling/rails_longtail/exports/.keep
Create tooling/rails_longtail/Gemfile:
# frozen_string_literal: true
source 'https://rubygems.org'
ruby '>= 3.2.0'
gem 'sqlite3', '~> 1.7'
gem 'httparty', '~> 0.22'
gem 'nokogiri', '~> 1.16'
gem 'feedjira', '~> 3.2'
group :test, :development do
gem 'minitest', '~> 5.22'
gem 'vcr', '~> 6.2'
gem 'webmock', '~> 3.23'
gem 'rake', '~> 13.2'
end
Create tooling/rails_longtail/.gitignore:
/data/*.sqlite
/data/*.sqlite-journal
/exports/*.csv
/.bundle/
/vendor/bundle/
Create tooling/rails_longtail/test/test_helper.rb:
# frozen_string_literal: true
require 'minitest/autorun'
require 'vcr'
require 'webmock/minitest'
require 'rails_longtail'
VCR.configure do |c|
c.cassette_library_dir = File.expand_path('fixtures/vcr_cassettes', __dir__)
c.hook_into :webmock
c.default_cassette_options = { record: :none, match_requests_on: %i[method uri] }
c.filter_sensitive_data('<GITHUB_TOKEN>') { ENV['GITHUB_TOKEN'] }
end
Create tooling/rails_longtail/lib/rails_longtail.rb:
# frozen_string_literal: true
require 'sqlite3'
module RailsLongtail
ROOT = File.expand_path('..', __dir__)
DB_PATH = File.join(ROOT, 'data', 'rails_longtail.sqlite')
end
require_relative 'rails_longtail/db'
Create tooling/rails_longtail/Rakefile:
# frozen_string_literal: true
require 'rake/testtask'
Rake::TestTask.new do |t|
t.libs << 'lib' << 'test'
t.pattern = 'test/**/*_test.rb'
t.warning = false
end
task default: :test
Run: cd tooling/rails_longtail && bundle install
Expected: gems install cleanly; Gemfile.lock produced.
cd /Users/me/work/joyrudder-dot-com
git add tooling/rails_longtail
git commit -m "scaffold rails long-tail discovery pipeline skeleton"
Files:
tooling/rails_longtail/schema.sqltooling/rails_longtail/lib/rails_longtail/db.rbtooling/rails_longtail/bin/bootstrap_dbCreate: tooling/rails_longtail/test/db_test.rb
Create tooling/rails_longtail/schema.sql — copy verbatim from spec Section 5:
CREATE TABLE IF NOT EXISTS companies (
id INTEGER PRIMARY KEY,
domain TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
headcount_tier TEXT,
hq_country TEXT,
hq_region TEXT,
website_url TEXT,
linkedin_url TEXT,
github_org TEXT,
long_tail_fit INTEGER,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS signals (
id INTEGER PRIMARY KEY,
company_id INTEGER NOT NULL REFERENCES companies(id),
source TEXT NOT NULL,
signal_type TEXT NOT NULL,
signal_date DATE,
evidence_url TEXT,
raw_text TEXT,
captured_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_signals_company ON signals(company_id);
CREATE INDEX IF NOT EXISTS idx_signals_source ON signals(source);
CREATE INDEX IF NOT EXISTS idx_signals_date ON signals(signal_date);
CREATE TABLE IF NOT EXISTS classifications (
id INTEGER PRIMARY KEY,
company_id INTEGER NOT NULL UNIQUE REFERENCES companies(id),
primary_vertical TEXT,
business_model TEXT,
confidence REAL,
llm_notes TEXT,
classified_by TEXT,
classified_at DATETIME DEFAULT CURRENT_TIMESTAMP,
audited INTEGER DEFAULT 0,
audit_verdict TEXT
);
CREATE TABLE IF NOT EXISTS source_runs (
id INTEGER PRIMARY KEY,
source TEXT NOT NULL,
started_at DATETIME,
finished_at DATETIME,
rows_added INTEGER,
notes TEXT
);
Create tooling/rails_longtail/test/db_test.rb:
# frozen_string_literal: true
require 'test_helper'
require 'fileutils'
require 'tmpdir'
class DbTest < Minitest::Test
def setup
@tmp = Dir.mktmpdir
@db_path = File.join(@tmp, 'test.sqlite')
end
def teardown
FileUtils.rm_rf(@tmp)
end
def test_open_creates_schema
db = RailsLongtail::DB.open(@db_path)
tables = db.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
assert_equal %w[classifications companies signals source_runs], tables.flatten.sort
ensure
db&.close
end
def test_open_is_idempotent
db1 = RailsLongtail::DB.open(@db_path)
db1.close
db2 = RailsLongtail::DB.open(@db_path)
tables = db2.execute("SELECT name FROM sqlite_master WHERE type='table'").flatten
assert_includes tables, 'companies'
db2.close
end
end
Run: cd tooling/rails_longtail && bundle exec rake test TEST=test/db_test.rb
Expected: FAIL with uninitialized constant RailsLongtail::DB.
Create tooling/rails_longtail/lib/rails_longtail/db.rb:
# frozen_string_literal: true
require 'sqlite3'
module RailsLongtail
module DB
SCHEMA_PATH = File.expand_path('../../schema.sql', __dir__)
def self.open(path = RailsLongtail::DB_PATH)
db = SQLite3::Database.new(path)
db.execute('PRAGMA foreign_keys = ON')
db.execute_batch(File.read(SCHEMA_PATH))
db
end
end
end
Run: bundle exec rake test TEST=test/db_test.rb
Expected: 2 tests, 0 failures.
Create tooling/rails_longtail/bin/bootstrap_db:
#!/usr/bin/env ruby
# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'rails_longtail'
db = RailsLongtail::DB.open
puts "Initialized #{RailsLongtail::DB_PATH}"
db.close
Make executable: chmod +x tooling/rails_longtail/bin/bootstrap_db
Run: cd tooling/rails_longtail && bin/bootstrap_db
Expected: prints Initialized .../data/rails_longtail.sqlite; file exists; sqlite3 data/rails_longtail.sqlite ".tables" lists 4 tables.
git add tooling/rails_longtail/schema.sql tooling/rails_longtail/lib/rails_longtail/db.rb \
tooling/rails_longtail/bin/bootstrap_db tooling/rails_longtail/test/db_test.rb
git commit -m "add sqlite schema and bootstrap_db"
Files:
tooling/rails_longtail/lib/rails_longtail/company.rbtooling/rails_longtail/lib/rails_longtail/signal.rbtooling/rails_longtail/lib/rails_longtail/source_run.rbtooling/rails_longtail/lib/rails_longtail.rbtooling/rails_longtail/test/company_test.rbtooling/rails_longtail/test/signal_test.rbCreate: tooling/rails_longtail/test/source_run_test.rb
Create tooling/rails_longtail/test/company_test.rb:
# frozen_string_literal: true
require 'test_helper'
require 'tmpdir'
require 'fileutils'
class CompanyTest < Minitest::Test
def setup
@tmp = Dir.mktmpdir
@db = RailsLongtail::DB.open(File.join(@tmp, 'test.sqlite'))
end
def teardown
@db.close
FileUtils.rm_rf(@tmp)
end
def test_upsert_inserts_new_company
id = RailsLongtail::Company.upsert_by_domain(@db, domain: 'acme.com', name: 'Acme')
assert id.positive?
row = @db.execute('SELECT domain, name FROM companies WHERE id = ?', id).first
assert_equal ['acme.com', 'Acme'], row
end
def test_upsert_returns_existing_id_without_overwriting
id1 = RailsLongtail::Company.upsert_by_domain(@db, domain: 'acme.com', name: 'Acme Inc')
id2 = RailsLongtail::Company.upsert_by_domain(@db, domain: 'acme.com', name: 'Acme Corp')
assert_equal id1, id2
name = @db.execute('SELECT name FROM companies WHERE id = ?', id1).first.first
assert_equal 'Acme Inc', name
end
def test_upsert_fills_null_fields_on_second_call
id = RailsLongtail::Company.upsert_by_domain(@db, domain: 'acme.com', name: 'Acme')
RailsLongtail::Company.upsert_by_domain(@db, domain: 'acme.com', name: 'Acme', github_org: 'acme')
row = @db.execute('SELECT github_org FROM companies WHERE id = ?', id).first
assert_equal ['acme'], row
end
def test_upsert_normalizes_domain_case
id1 = RailsLongtail::Company.upsert_by_domain(@db, domain: 'ACME.com', name: 'Acme')
id2 = RailsLongtail::Company.upsert_by_domain(@db, domain: 'acme.com', name: 'Acme')
assert_equal id1, id2
end
end
Run: bundle exec rake test TEST=test/company_test.rb
Expected: FAIL with uninitialized constant RailsLongtail::Company.
Create tooling/rails_longtail/lib/rails_longtail/company.rb:
# frozen_string_literal: true
module RailsLongtail
module Company
UPSERTABLE = %w[headcount_tier hq_country hq_region website_url linkedin_url github_org notes].freeze
def self.upsert_by_domain(db, domain:, name:, **extras)
canonical = domain.strip.downcase
db.execute(
'INSERT OR IGNORE INTO companies (domain, name) VALUES (?, ?)',
[canonical, name]
)
id = db.execute('SELECT id FROM companies WHERE domain = ?', canonical).first.first
extras.slice(*UPSERTABLE.map(&:to_sym)).each do |field, value|
next if value.nil? || value.to_s.empty?
db.execute(
"UPDATE companies SET #{field} = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND #{field} IS NULL",
[value.to_s, id]
)
end
id
end
end
end
Modify tooling/rails_longtail/lib/rails_longtail.rb. After the existing require_relative 'rails_longtail/db' line, add:
require_relative 'rails_longtail/company'
Run: bundle exec rake test TEST=test/company_test.rb
Expected: 4 tests, 0 failures.
Create tooling/rails_longtail/test/signal_test.rb:
# frozen_string_literal: true
require 'test_helper'
require 'tmpdir'
require 'fileutils'
class SignalTest < Minitest::Test
def setup
@tmp = Dir.mktmpdir
@db = RailsLongtail::DB.open(File.join(@tmp, 'test.sqlite'))
@company_id = RailsLongtail::Company.upsert_by_domain(@db, domain: 'acme.com', name: 'Acme')
end
def teardown
@db.close
FileUtils.rm_rf(@tmp)
end
def test_insert_appends_row
RailsLongtail::Signal.insert(
@db,
company_id: @company_id,
source: 'github',
signal_type: 'code',
signal_date: '2026-01-01',
evidence_url: 'https://github.com/acme/app',
raw_text: 'gem "rails"'
)
rows = @db.execute('SELECT source, signal_type, evidence_url FROM signals')
assert_equal [['github', 'code', 'https://github.com/acme/app']], rows
end
def test_insert_is_append_only
3.times do
RailsLongtail::Signal.insert(
@db,
company_id: @company_id,
source: 'github',
signal_type: 'code',
evidence_url: 'https://github.com/acme/app'
)
end
count = @db.execute('SELECT COUNT(*) FROM signals').first.first
assert_equal 3, count
end
end
Run: bundle exec rake test TEST=test/signal_test.rb
Expected: FAIL with uninitialized constant RailsLongtail::Signal.
Create tooling/rails_longtail/lib/rails_longtail/signal.rb:
# frozen_string_literal: true
module RailsLongtail
module Signal
def self.insert(db, company_id:, source:, signal_type:, signal_date: nil, evidence_url: nil, raw_text: nil)
db.execute(
'INSERT INTO signals (company_id, source, signal_type, signal_date, evidence_url, raw_text) VALUES (?, ?, ?, ?, ?, ?)',
[company_id, source, signal_type, signal_date, evidence_url, raw_text]
)
end
end
end
Add require_relative 'rails_longtail/signal' to lib/rails_longtail.rb.
Run: bundle exec rake test TEST=test/signal_test.rb
Expected: 2 tests, 0 failures.
Create tooling/rails_longtail/test/source_run_test.rb:
# frozen_string_literal: true
require 'test_helper'
require 'tmpdir'
require 'fileutils'
class SourceRunTest < Minitest::Test
def setup
@tmp = Dir.mktmpdir
@db = RailsLongtail::DB.open(File.join(@tmp, 'test.sqlite'))
end
def teardown
@db.close
FileUtils.rm_rf(@tmp)
end
def test_start_returns_id_and_records_start_time
id = RailsLongtail::SourceRun.start(@db, 'github')
row = @db.execute('SELECT source, started_at, finished_at FROM source_runs WHERE id = ?', id).first
assert_equal 'github', row[0]
refute_nil row[1]
assert_nil row[2]
end
def test_finish_records_end_time_and_rows_added
id = RailsLongtail::SourceRun.start(@db, 'github')
RailsLongtail::SourceRun.finish(@db, id, rows_added: 42, notes: 'ok')
row = @db.execute('SELECT finished_at, rows_added, notes FROM source_runs WHERE id = ?', id).first
refute_nil row[0]
assert_equal 42, row[1]
assert_equal 'ok', row[2]
end
end
Create tooling/rails_longtail/lib/rails_longtail/source_run.rb:
# frozen_string_literal: true
module RailsLongtail
module SourceRun
def self.start(db, source)
db.execute('INSERT INTO source_runs (source, started_at) VALUES (?, CURRENT_TIMESTAMP)', [source])
db.last_insert_row_id
end
def self.finish(db, id, rows_added:, notes: nil)
db.execute(
'UPDATE source_runs SET finished_at = CURRENT_TIMESTAMP, rows_added = ?, notes = ? WHERE id = ?',
[rows_added, notes, id]
)
end
end
end
Add require_relative 'rails_longtail/source_run' to lib/rails_longtail.rb.
Run: bundle exec rake test
Expected: all tests pass (DbTest + CompanyTest + SignalTest + SourceRunTest).
git add tooling/rails_longtail/lib tooling/rails_longtail/test
git commit -m "add Company, Signal, SourceRun upsert helpers"
Files:
tooling/rails_longtail/lib/rails_longtail/github.rbtooling/rails_longtail/bin/ingest_githubtooling/rails_longtail/test/github_test.rbtooling/rails_longtail/test/fixtures/vcr_cassettes/github_code_search.yml (captured in step 5)Watch out for: GitHub code search requires authentication and is heavily rate-limited (5-30 req/min). Use ENV['GITHUB_TOKEN'] (a classic PAT with public_repo + read:org scopes is sufficient). Results paginate at 100/page, max 1,000 total per query — so we query in batches with narrower qualifiers (language + filename + extension combinations).
Create tooling/rails_longtail/test/github_test.rb:
# frozen_string_literal: true
require 'test_helper'
require 'tmpdir'
require 'fileutils'
class GithubTest < Minitest::Test
def setup
@tmp = Dir.mktmpdir
@db = RailsLongtail::DB.open(File.join(@tmp, 'test.sqlite'))
end
def teardown
@db.close
FileUtils.rm_rf(@tmp)
end
def test_search_rails_gemfiles_upserts_companies_and_signals
VCR.use_cassette('github_code_search') do
RailsLongtail::Github.ingest(@db, pages: 1)
end
company_count = @db.execute('SELECT COUNT(*) FROM companies').first.first
assert company_count.positive?, 'expected at least one company upserted'
signal_count = @db.execute("SELECT COUNT(*) FROM signals WHERE source = 'github'").first.first
assert signal_count.positive?, 'expected at least one github signal row'
sample = @db.execute(
"SELECT c.domain, s.signal_type, s.evidence_url
FROM signals s JOIN companies c ON c.id = s.company_id
WHERE s.source = 'github' LIMIT 1"
).first
refute_nil sample[0]
assert_equal 'code', sample[1]
assert_match %r{github\.com/}, sample[2]
end
def test_ingest_skips_personal_accounts
VCR.use_cassette('github_code_search') do
RailsLongtail::Github.ingest(@db, pages: 1)
end
notes = @db.execute("SELECT notes FROM source_runs WHERE source = 'github' ORDER BY id DESC LIMIT 1").first.first
assert_match(/skipped \d+ personal accounts/, notes || '')
end
end
Run: bundle exec rake test TEST=test/github_test.rb
Expected: FAIL with uninitialized constant RailsLongtail::Github.
Create tooling/rails_longtail/lib/rails_longtail/github.rb:
# frozen_string_literal: true
require 'httparty'
module RailsLongtail
module Github
API = 'https://api.github.com'
CODE_QUERY = 'gem+%22rails%22+filename:Gemfile'
def self.ingest(db, pages: 10)
run_id = SourceRun.start(db, 'github')
added = 0
skipped_personal = 0
(1..pages).each do |page|
resp = HTTParty.get(
"#{API}/search/code?q=#{CODE_QUERY}&per_page=100&page=#{page}",
headers: auth_headers
)
break unless resp.code == 200
Array(resp.parsed_response['items']).each do |item|
repo = item['repository']
owner = repo['owner']
if owner['type'] != 'Organization'
skipped_personal += 1
next
end
org_login = owner['login']
org_info = fetch_org(org_login)
next unless org_info && org_info['blog'] && !org_info['blog'].empty?
domain = normalize_domain(org_info['blog'])
next if domain.nil? || domain.empty?
company_id = Company.upsert_by_domain(
db,
domain: domain,
name: org_info['name'] || org_login,
github_org: org_login,
website_url: org_info['blog'],
notes: org_info['description']
)
Signal.insert(
db,
company_id: company_id,
source: 'github',
signal_type: 'code',
signal_date: Date.today.to_s,
evidence_url: repo['html_url'],
raw_text: "Found 'gem \"rails\"' in #{repo['full_name']}/Gemfile"
)
added += 1
end
end
SourceRun.finish(db, run_id, rows_added: added, notes: "skipped #{skipped_personal} personal accounts")
added
end
def self.fetch_org(login)
resp = HTTParty.get("#{API}/orgs/#{login}", headers: auth_headers)
resp.code == 200 ? resp.parsed_response : nil
end
def self.normalize_domain(url)
return nil unless url
u = url.strip
u = "https://#{u}" unless u.match?(%r{^https?://})
host = URI.parse(u).host
host&.sub(/^www\./, '')
rescue URI::InvalidURIError
nil
end
def self.auth_headers
token = ENV.fetch('GITHUB_TOKEN') { raise 'GITHUB_TOKEN env var required' }
{
'Accept' => 'application/vnd.github+json',
'Authorization' => "Bearer #{token}",
'X-GitHub-Api-Version' => '2022-11-28',
'User-Agent' => 'joyrudder-rails-longtail'
}
end
end
end
Add require_relative 'rails_longtail/github' to lib/rails_longtail.rb.
Set GITHUB_TOKEN and allow VCR to record once:
export GITHUB_TOKEN=<your pat>
cd tooling/rails_longtail
# temporarily change test_helper.rb record: :none → record: :new_episodes
# then:
bundle exec rake test TEST=test/github_test.rb
# then revert record: :new_episodes back to record: :none
Verify test/fixtures/vcr_cassettes/github_code_search.yml was created and contains real fixture data with <GITHUB_TOKEN> redacted.
Run: bundle exec rake test TEST=test/github_test.rb
Expected: 2 tests, 0 failures.
Create tooling/rails_longtail/bin/ingest_github:
#!/usr/bin/env ruby
# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'rails_longtail'
pages = Integer(ENV.fetch('PAGES', '10'))
db = RailsLongtail::DB.open
added = RailsLongtail::Github.ingest(db, pages: pages)
puts "github: #{added} signals added across #{pages} pages"
db.close
Make executable: chmod +x tooling/rails_longtail/bin/ingest_github
git add tooling/rails_longtail/lib/rails_longtail/github.rb \
tooling/rails_longtail/bin/ingest_github tooling/rails_longtail/test/github_test.rb \
tooling/rails_longtail/test/fixtures tooling/rails_longtail/lib/rails_longtail.rb
git commit -m "add github code-search ingest"
Files:
tooling/rails_longtail/lib/rails_longtail/hn_hiring.rbtooling/rails_longtail/bin/ingest_hn_hiringtooling/rails_longtail/test/hn_hiring_test.rbtooling/rails_longtail/test/fixtures/vcr_cassettes/hn_hiring.ymlApproach: HN’s Ask HN: Who is hiring? monthly threads are indexed by the Algolia HN Search API (https://hn.algolia.com/api/v1). We fetch the parent thread IDs for the last 12 months, pull each thread’s comments (each comment is one job post), and parse out company name + domain + Rails mention.
Watch out for: HN comments are free-form text. Companies often self-identify as “Acme (acme.com)” at the start; we use a simple regex pair for that pattern. If parsing fails, skip the comment and log to source_runs.notes.
Create tooling/rails_longtail/test/hn_hiring_test.rb:
# frozen_string_literal: true
require 'test_helper'
require 'tmpdir'
require 'fileutils'
class HnHiringTest < Minitest::Test
def setup
@tmp = Dir.mktmpdir
@db = RailsLongtail::DB.open(File.join(@tmp, 'test.sqlite'))
end
def teardown
@db.close
FileUtils.rm_rf(@tmp)
end
def test_ingest_extracts_rails_hiring_signals
VCR.use_cassette('hn_hiring') do
RailsLongtail::HnHiring.ingest(@db, months_back: 3)
end
rows = @db.execute(
"SELECT c.domain, s.signal_type, s.signal_date
FROM signals s JOIN companies c ON c.id = s.company_id
WHERE s.source = 'hn-hiring'"
)
assert rows.any?
refute_nil rows.first[2]
end
def test_parse_comment_extracts_name_domain
text = 'Acme (https://acme.com) | Senior Ruby on Rails Engineer | Remote | Full-time'
parsed = RailsLongtail::HnHiring.parse_comment(text)
assert_equal 'Acme', parsed[:name]
assert_equal 'acme.com', parsed[:domain]
assert parsed[:rails_mention]
end
def test_parse_comment_returns_nil_without_rails
text = 'FooCo (https://foo.com) | Go Engineer | NYC'
assert_nil RailsLongtail::HnHiring.parse_comment(text)
end
end
Run: bundle exec rake test TEST=test/hn_hiring_test.rb
Expected: FAIL with uninitialized constant RailsLongtail::HnHiring.
Create tooling/rails_longtail/lib/rails_longtail/hn_hiring.rb:
# frozen_string_literal: true
require 'httparty'
require 'date'
module RailsLongtail
module HnHiring
SEARCH = 'https://hn.algolia.com/api/v1/search'
ITEM = 'https://hn.algolia.com/api/v1/items'
RAILS_RX = /\b(Rails|Ruby on Rails|RoR)\b/i
HEAD_RX = /\A\s*([^|(]+?)\s*[|(]\s*(?:https?:\/\/)?([^)\s\/]+\.[a-z]{2,})/i
def self.ingest(db, months_back: 12)
run_id = SourceRun.start(db, 'hn-hiring')
added = 0
thread_ids_last_months(months_back).each do |thread_id|
thread = fetch_item(thread_id)
next unless thread
date = Time.at(thread['created_at_i']).to_date.to_s
Array(thread['children']).each do |child|
comment = (child['text'] || '').gsub(/<[^>]+>/, ' ')
parsed = parse_comment(comment)
next unless parsed
company_id = Company.upsert_by_domain(db, domain: parsed[:domain], name: parsed[:name])
Signal.insert(
db,
company_id: company_id,
source: 'hn-hiring',
signal_type: 'job_post',
signal_date: date,
evidence_url: "https://news.ycombinator.com/item?id=#{child['id']}",
raw_text: comment[0, 500]
)
added += 1
end
end
SourceRun.finish(db, run_id, rows_added: added)
added
end
def self.thread_ids_last_months(months_back)
cutoff = Date.today << months_back
resp = HTTParty.get("#{SEARCH}?query=Ask+HN+Who+is+hiring&tags=story&hitsPerPage=24")
return [] unless resp.code == 200
Array(resp.parsed_response['hits'])
.select { |h| h['title'].to_s.match?(/who is hiring/i) }
.select { |h| Date.parse(h['created_at']) >= cutoff }
.map { |h| h['objectID'] }
end
def self.fetch_item(id)
resp = HTTParty.get("#{ITEM}/#{id}")
resp.code == 200 ? resp.parsed_response : nil
end
def self.parse_comment(text)
return nil unless text.match?(RAILS_RX)
m = text.match(HEAD_RX)
return nil unless m
name = m[1].strip
domain = m[2].strip.downcase.sub(/^www\./, '')
return nil if domain.empty? || name.empty?
{ name: name, domain: domain, rails_mention: true }
end
end
end
Add require_relative 'rails_longtail/hn_hiring' to lib/rails_longtail.rb.
Step 4: Record VCR cassette (see Task 4 Step 4 for method).
Step 5: Run test to verify green
Run: bundle exec rake test TEST=test/hn_hiring_test.rb
Expected: 3 tests, 0 failures.
Create tooling/rails_longtail/bin/ingest_hn_hiring:
#!/usr/bin/env ruby
# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'rails_longtail'
months = Integer(ENV.fetch('MONTHS', '12'))
db = RailsLongtail::DB.open
added = RailsLongtail::HnHiring.ingest(db, months_back: months)
puts "hn-hiring: #{added} signals added (last #{months} months)"
db.close
Make executable and commit with a message like add hn 'who is hiring' ingest.
Files:
tooling/rails_longtail/lib/rails_longtail/job_feeds.rbtooling/rails_longtail/bin/ingest_job_feedstooling/rails_longtail/test/job_feeds_test.rbtooling/rails_longtail/test/fixtures/vcr_cassettes/wwr_ruby.yml, remoteok_ruby.ymlEndpoints:
https://weworkremotely.com/categories/remote-ruby-on-rails-jobs.rssRemoteOK Ruby tag: https://remoteok.com/api?tags=ruby (returns JSON array)
Create tooling/rails_longtail/test/job_feeds_test.rb:
# frozen_string_literal: true
require 'test_helper'
require 'tmpdir'
require 'fileutils'
class JobFeedsTest < Minitest::Test
def setup
@tmp = Dir.mktmpdir
@db = RailsLongtail::DB.open(File.join(@tmp, 'test.sqlite'))
end
def teardown
@db.close
FileUtils.rm_rf(@tmp)
end
def test_ingest_wwr_upserts_companies_and_signals
VCR.use_cassette('wwr_ruby') do
RailsLongtail::JobFeeds.ingest_wwr(@db)
end
count = @db.execute("SELECT COUNT(*) FROM signals WHERE source = 'wwr'").first.first
assert count.positive?
end
def test_ingest_remoteok_upserts_companies_and_signals
VCR.use_cassette('remoteok_ruby') do
RailsLongtail::JobFeeds.ingest_remoteok(@db)
end
count = @db.execute("SELECT COUNT(*) FROM signals WHERE source = 'remoteok'").first.first
assert count.positive?
end
end
Run: bundle exec rake test TEST=test/job_feeds_test.rb
Expected: FAIL on uninitialized constant RailsLongtail::JobFeeds.
Create tooling/rails_longtail/lib/rails_longtail/job_feeds.rb:
# frozen_string_literal: true
require 'feedjira'
require 'httparty'
module RailsLongtail
module JobFeeds
WWR_URL = 'https://weworkremotely.com/categories/remote-ruby-on-rails-jobs.rss'
REMOTEOK_URL = 'https://remoteok.com/api?tags=ruby'
COMPANY_DOMAIN_RX = /\b([a-z0-9][-a-z0-9]*\.[a-z]{2,})\b/i
def self.ingest_wwr(db)
run_id = SourceRun.start(db, 'wwr')
added = 0
resp = HTTParty.get(WWR_URL, headers: { 'User-Agent' => 'joyrudder-rails-longtail' })
return 0 unless resp.code == 200
feed = Feedjira.parse(resp.body)
Array(feed.entries).each do |entry|
next unless entry.title.match?(/Rails|Ruby/i) || entry.summary.to_s.match?(/Rails/i)
company_name, domain = extract_company(entry.title, entry.url)
next unless domain
company_id = Company.upsert_by_domain(db, domain: domain, name: company_name)
Signal.insert(
db,
company_id: company_id,
source: 'wwr',
signal_type: 'job_post',
signal_date: entry.published&.to_date&.to_s,
evidence_url: entry.url,
raw_text: "#{entry.title} -- #{entry.summary.to_s[0, 400]}"
)
added += 1
end
SourceRun.finish(db, run_id, rows_added: added)
added
end
def self.ingest_remoteok(db)
run_id = SourceRun.start(db, 'remoteok')
added = 0
resp = HTTParty.get(REMOTEOK_URL, headers: { 'User-Agent' => 'joyrudder-rails-longtail' })
return 0 unless resp.code == 200
Array(resp.parsed_response).each do |job|
next unless job.is_a?(Hash)
next unless Array(job['tags']).map(&:to_s).any? { |t| t.match?(/\bruby\b/i) }
company_name = job['company'] || next
domain = guess_domain(job['company_logo'] || job['url'] || '')
next unless domain
company_id = Company.upsert_by_domain(db, domain: domain, name: company_name)
Signal.insert(
db,
company_id: company_id,
source: 'remoteok',
signal_type: 'job_post',
signal_date: (Date.parse(job['date']) rescue nil)&.to_s,
evidence_url: job['url'],
raw_text: job['description'].to_s[0, 500]
)
added += 1
end
SourceRun.finish(db, run_id, rows_added: added)
added
end
def self.extract_company(title, url)
# Titles often look like: "ACME: Senior Rails Engineer" or "Senior Rails Engineer at ACME"
name = title.split(/[:|\-]|\bat\b/i).map(&:strip).reject(&:empty?).first
domain = guess_domain(url || '')
[name, domain]
end
def self.guess_domain(url)
return nil if url.nil? || url.empty?
match = url.match(COMPANY_DOMAIN_RX)
return nil unless match
host = match[1].downcase.sub(/^www\./, '')
return nil if %w[weworkremotely.com remoteok.com].include?(host)
host
end
end
end
Add require_relative 'rails_longtail/job_feeds' to lib/rails_longtail.rb.
Step 4: Record VCR cassettes for both feeds.
Step 5: Run test to verify green
Run: bundle exec rake test TEST=test/job_feeds_test.rb
Expected: 2 tests, 0 failures.
Create tooling/rails_longtail/bin/ingest_job_feeds:
#!/usr/bin/env ruby
# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'rails_longtail'
db = RailsLongtail::DB.open
w = RailsLongtail::JobFeeds.ingest_wwr(db)
r = RailsLongtail::JobFeeds.ingest_remoteok(db)
puts "job-feeds: wwr=#{w}, remoteok=#{r}"
db.close
Make executable and commit.
Files:
tooling/rails_longtail/lib/rails_longtail/board_dorks.rbtooling/rails_longtail/bin/ingest_board_dorkstooling/rails_longtail/test/board_dorks_test.rbApproach: DuckDuckGo’s HTML endpoint (https://html.duckduckgo.com/html/) accepts a q POST parameter and returns static HTML we can parse with Nokogiri. We run three queries:
site:boards.greenhouse.io "Ruby on Rails"site:jobs.lever.co "Ruby on Rails"site:jobs.ashbyhq.com "Ruby on Rails"Each result URL contains /<company-slug>/ which we use as both the canonical company identifier and the synthetic domain (e.g., company-slug.greenhouse.io). We use the synthetic domain so the same company won’t dedupe incorrectly if it also appears in GitHub or WWR with its real domain — but see Task 10 where classification can manually consolidate if needed.
Watch out for: DDG rate-limits aggressive scraping. Use 2-second sleep between queries. If this source drops to zero results, fall back to manually-curated docs/board_dork_seed_companies.yml (create in this task as a fallback list).
Create tooling/rails_longtail/test/board_dorks_test.rb:
# frozen_string_literal: true
require 'test_helper'
require 'tmpdir'
require 'fileutils'
class BoardDorksTest < Minitest::Test
def setup
@tmp = Dir.mktmpdir
@db = RailsLongtail::DB.open(File.join(@tmp, 'test.sqlite'))
end
def teardown
@db.close
FileUtils.rm_rf(@tmp)
end
def test_parse_extracts_company_slugs_from_ddg_html
html = File.read(File.join(__dir__, 'fixtures', 'ddg_greenhouse.html'))
slugs = RailsLongtail::BoardDorks.parse_greenhouse(html)
assert slugs.size.positive?
assert slugs.all? { |s| s =~ /\A[a-z0-9-]+\z/ }
end
def test_ingest_upserts_board_dork_signals
VCR.use_cassette('board_dorks') do
RailsLongtail::BoardDorks.ingest(@db)
end
count = @db.execute("SELECT COUNT(*) FROM signals WHERE source = 'board-dorks'").first.first
assert count.positive?
end
end
Create tooling/rails_longtail/test/fixtures/ddg_greenhouse.html by hand — save a sample DDG HTML result for site:boards.greenhouse.io "Ruby on Rails" (browser → view source → save). Any ~5 result entries will do.
Run: bundle exec rake test TEST=test/board_dorks_test.rb
Expected: FAIL on uninitialized constant RailsLongtail::BoardDorks.
Create tooling/rails_longtail/lib/rails_longtail/board_dorks.rb:
# frozen_string_literal: true
require 'httparty'
require 'nokogiri'
module RailsLongtail
module BoardDorks
DDG = 'https://html.duckduckgo.com/html/'
BOARDS = {
'greenhouse' => { host: 'boards.greenhouse.io', url_rx: %r{boards\.greenhouse\.io/(?:embed/job_app\?for=)?([a-z0-9-]+)}i },
'lever' => { host: 'jobs.lever.co', url_rx: %r{jobs\.lever\.co/([a-z0-9-]+)}i },
'ashby' => { host: 'jobs.ashbyhq.com', url_rx: %r{jobs\.ashbyhq\.com/([a-z0-9-]+)}i }
}.freeze
def self.ingest(db)
run_id = SourceRun.start(db, 'board-dorks')
added = 0
BOARDS.each_pair do |board, cfg|
html = search_ddg(%(site:#{cfg[:host]} "Ruby on Rails"))
slugs = parse(html, cfg[:url_rx])
slugs.each do |slug|
domain = "#{slug}.#{cfg[:host].split('.').last(2).join('.')}"
company_id = Company.upsert_by_domain(db, domain: domain, name: slug.tr('-', ' ').capitalize)
Signal.insert(
db,
company_id: company_id,
source: 'board-dorks',
signal_type: 'job_post',
evidence_url: "https://#{cfg[:host]}/#{slug}",
raw_text: "#{board} board lists #{slug} with Ruby on Rails"
)
added += 1
end
sleep 2
end
SourceRun.finish(db, run_id, rows_added: added)
added
end
def self.search_ddg(query)
resp = HTTParty.post(
DDG,
body: { q: query },
headers: { 'User-Agent' => 'Mozilla/5.0 joyrudder-rails-longtail' }
)
resp.code == 200 ? resp.body : ''
end
def self.parse(html, rx)
doc = Nokogiri::HTML(html)
hrefs = doc.css('a.result__a, a.result__url').map { |a| a['href'].to_s }
hrefs.flat_map { |h| h.scan(rx) }.flatten.uniq
end
def self.parse_greenhouse(html)
parse(html, BOARDS['greenhouse'][:url_rx])
end
end
end
Add require_relative 'rails_longtail/board_dorks' to lib/rails_longtail.rb.
Step 5: Record VCR cassette.
Step 6: Run tests to verify green
Run: bundle exec rake test TEST=test/board_dorks_test.rb
Expected: 2 tests, 0 failures.
Create tooling/rails_longtail/bin/ingest_board_dorks:
#!/usr/bin/env ruby
# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'rails_longtail'
db = RailsLongtail::DB.open
added = RailsLongtail::BoardDorks.ingest(db)
puts "board-dorks: #{added} signals added"
db.close
Make executable and commit.
Files:
tooling/rails_longtail/lib/rails_longtail/rails_foundation.rbtooling/rails_longtail/bin/ingest_rails_foundationtooling/rails_longtail/test/rails_foundation_test.rbApproach: https://rubyonrails.org/foundation/ lists members/sponsors as linked tiles. Each tile contains company name + URL. We scrape with Nokogiri, extract company + domain, and record a sponsor signal.
Create tooling/rails_longtail/test/rails_foundation_test.rb:
# frozen_string_literal: true
require 'test_helper'
require 'tmpdir'
require 'fileutils'
class RailsFoundationTest < Minitest::Test
def setup
@tmp = Dir.mktmpdir
@db = RailsLongtail::DB.open(File.join(@tmp, 'test.sqlite'))
end
def teardown
@db.close
FileUtils.rm_rf(@tmp)
end
def test_parse_extracts_member_tiles
html = File.read(File.join(__dir__, 'fixtures', 'rails_foundation.html'))
entries = RailsLongtail::RailsFoundation.parse(html)
assert entries.size.positive?
entries.each do |e|
assert e[:name], "entry has name"
assert e[:domain], "entry has domain"
end
end
def test_ingest_writes_sponsor_signals
VCR.use_cassette('rails_foundation') do
RailsLongtail::RailsFoundation.ingest(@db)
end
count = @db.execute("SELECT COUNT(*) FROM signals WHERE source = 'railsfdn'").first.first
assert count.positive?
end
end
Save a hand-captured test/fixtures/rails_foundation.html fixture.
Expected: uninitialized constant RailsLongtail::RailsFoundation.
Create tooling/rails_longtail/lib/rails_longtail/rails_foundation.rb:
# frozen_string_literal: true
require 'httparty'
require 'nokogiri'
module RailsLongtail
module RailsFoundation
URL = 'https://rubyonrails.org/foundation/'
DOMAIN_RX = /\b([a-z0-9][-a-z0-9]*\.[a-z]{2,})/i
def self.ingest(db)
run_id = SourceRun.start(db, 'railsfdn')
added = 0
resp = HTTParty.get(URL, headers: { 'User-Agent' => 'joyrudder-rails-longtail' })
return 0 unless resp.code == 200
parse(resp.body).each do |entry|
company_id = Company.upsert_by_domain(db, domain: entry[:domain], name: entry[:name])
Signal.insert(
db,
company_id: company_id,
source: 'railsfdn',
signal_type: 'sponsor',
signal_date: Date.today.to_s,
evidence_url: URL,
raw_text: "Rails Foundation: #{entry[:name]}"
)
added += 1
end
SourceRun.finish(db, run_id, rows_added: added)
added
end
def self.parse(html)
doc = Nokogiri::HTML(html)
anchors = doc.css('a[href]').select { |a| a['href'].to_s.match?(%r{^https?://}) }
anchors.uniq { |a| a['href'] }.filter_map do |a|
href = a['href']
name = (a['title'] || a.text.strip).to_s.strip
next if name.empty?
host = URI.parse(href).host&.sub(/^www\./, '')
next unless host && host.include?('.')
next if %w[rubyonrails.org github.com twitter.com x.com linkedin.com].include?(host)
{ name: name, domain: host.downcase }
rescue URI::InvalidURIError
nil
end
end
end
end
Add require to lib/rails_longtail.rb.
Step 4: Record VCR cassette.
Step 5: Run test to verify green.
Step 6: Write ingest_rails_foundation binary
Create tooling/rails_longtail/bin/ingest_rails_foundation:
#!/usr/bin/env ruby
# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'rails_longtail'
db = RailsLongtail::DB.open
added = RailsLongtail::RailsFoundation.ingest(db)
puts "rails-foundation: #{added} signals added"
db.close
Make executable: chmod +x tooling/rails_longtail/bin/ingest_rails_foundation
git add tooling/rails_longtail/lib/rails_longtail/rails_foundation.rb \
tooling/rails_longtail/bin/ingest_rails_foundation \
tooling/rails_longtail/test/rails_foundation_test.rb \
tooling/rails_longtail/test/fixtures tooling/rails_longtail/lib/rails_longtail.rb
git commit -m "add rails foundation directory ingest"
Files:
tooling/rails_longtail/lib/rails_longtail/conferences.rbtooling/rails_longtail/bin/ingest_conferencestooling/rails_longtail/test/conferences_test.rbtooling/rails_longtail/docs/conference_urls.ymlApproach: Each conference has a sponsor page with a year in the URL (e.g., railsconf.org/2024/sponsors). Conferences and years are enumerated in a YAML config so it’s trivial to add more. Parser logic mirrors Task 8 — anchor tags with logos.
# tooling/rails_longtail/docs/conference_urls.yml
railsconf:
- 2023: https://railsconf.org/2023/sponsors
- 2024: https://railsconf.org/2024/sponsors
- 2025: https://railsconf.org/2025/sponsors
rubykaigi:
- 2023: https://rubykaigi.org/2023/sponsors/
- 2024: https://rubykaigi.org/2024/sponsors/
- 2025: https://rubykaigi.org/2025/sponsors/
euruko:
- 2023: https://2023.euruko.org/sponsors/
- 2024: https://2024.euruko.org/sponsors
rubyconf:
- 2023: https://rubyconf.org/2023/sponsors
- 2024: https://rubyconf.org/2024/sponsors
Create tooling/rails_longtail/test/fixtures/conf_railsconf_2024.html by hand (save a real RailsConf 2024 sponsor page).
Create tooling/rails_longtail/test/conferences_test.rb:
# frozen_string_literal: true
require 'test_helper'
require 'tmpdir'
require 'fileutils'
class ConferencesTest < Minitest::Test
def setup
@tmp = Dir.mktmpdir
@db = RailsLongtail::DB.open(File.join(@tmp, 'test.sqlite'))
end
def teardown
@db.close
FileUtils.rm_rf(@tmp)
end
def test_parse_extracts_sponsor_entries
html = File.read(File.join(__dir__, 'fixtures', 'conf_railsconf_2024.html'))
entries = RailsLongtail::Conferences.parse(html)
assert entries.size.positive?
entries.each do |e|
assert e[:domain].include?('.')
refute e[:domain].start_with?('railsconf')
end
end
def test_ingest_writes_conf_sponsor_signals
VCR.use_cassette('conferences_railsconf_2024') do
RailsLongtail::Conferences.ingest(@db)
end
count = @db.execute("SELECT COUNT(*) FROM signals WHERE source = 'conf-sponsors'").first.first
assert count.positive?
end
end
Step 3: Run test to verify failure.
Step 4: Implement Conferences
Create tooling/rails_longtail/lib/rails_longtail/conferences.rb:
# frozen_string_literal: true
require 'httparty'
require 'nokogiri'
require 'yaml'
module RailsLongtail
module Conferences
CONFIG_PATH = File.expand_path('../../docs/conference_urls.yml', __dir__)
def self.ingest(db)
run_id = SourceRun.start(db, 'conf-sponsors')
added = 0
config = YAML.load_file(CONFIG_PATH)
config.each do |conf, years|
Array(years).each do |entry|
year, url = entry.first
resp = HTTParty.get(url, headers: { 'User-Agent' => 'joyrudder-rails-longtail' })
next unless resp.code == 200
parse(resp.body).each do |e|
company_id = Company.upsert_by_domain(db, domain: e[:domain], name: e[:name])
Signal.insert(
db,
company_id: company_id,
source: 'conf-sponsors',
signal_type: 'sponsor',
signal_date: "#{year}-01-01",
evidence_url: url,
raw_text: "#{conf} #{year} sponsor: #{e[:name]}"
)
added += 1
end
sleep 1
end
end
SourceRun.finish(db, run_id, rows_added: added)
added
end
def self.parse(html)
doc = Nokogiri::HTML(html)
doc.css('a[href]').filter_map do |a|
href = a['href'].to_s
next unless href.match?(%r{^https?://})
host = URI.parse(href).host&.sub(/^www\./, '')
next unless host && host.include?('.')
next if host.match?(/(rubykaigi|railsconf|euruko|rubyconf|github|twitter|x|linkedin)\./)
name = (a['title'] || a.text.strip)
name = host.split('.').first.capitalize if name.nil? || name.empty?
{ name: name, domain: host.downcase }
rescue URI::InvalidURIError
nil
end.uniq { |e| e[:domain] }
end
end
end
Add require to lib/rails_longtail.rb.
Step 5: Record VCR cassette.
Step 6: Run test to verify green.
Step 7: Write ingest_conferences binary
Create tooling/rails_longtail/bin/ingest_conferences:
#!/usr/bin/env ruby
# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'rails_longtail'
db = RailsLongtail::DB.open
added = RailsLongtail::Conferences.ingest(db)
puts "conferences: #{added} signals added"
db.close
Make executable.
git add tooling/rails_longtail/lib/rails_longtail/conferences.rb \
tooling/rails_longtail/bin/ingest_conferences tooling/rails_longtail/docs/conference_urls.yml \
tooling/rails_longtail/test/conferences_test.rb tooling/rails_longtail/test/fixtures \
tooling/rails_longtail/lib/rails_longtail.rb
git commit -m "add conference sponsor ingest"
Files:
tooling/rails_longtail/lib/rails_longtail/gems.rbtooling/rails_longtail/bin/ingest_gemstooling/rails_longtail/test/gems_test.rbApproach: https://rubygems.org/api/v1/versions/popular.json is not available, so we use https://rubygems.org/downloads/total.json (aggregate) and hit https://rubygems.org/api/v1/gems.json?page=N with page=1..10 to walk the top-downloaded gems (100 per page → 1,000 gems total). For each gem we fetch GET /api/v1/gems/{name}.json and look at source_code_uri / homepage_uri. If it maps to a GitHub org (not a personal account), we upsert. Filter: org must have a homepage with a valid domain.
Watch out for: rubygems.org rate-limit is 400 req/min per IP. 1,000 gems × 1 request each → ~3 min with a tiny sleep.
Create tooling/rails_longtail/test/gems_test.rb:
# frozen_string_literal: true
require 'test_helper'
require 'tmpdir'
require 'fileutils'
class GemsTest < Minitest::Test
def setup
@tmp = Dir.mktmpdir
@db = RailsLongtail::DB.open(File.join(@tmp, 'test.sqlite'))
end
def teardown
@db.close
FileUtils.rm_rf(@tmp)
end
def test_extract_github_org_from_source_uri
assert_equal 'rails', RailsLongtail::Gems.extract_github_org('https://github.com/rails/rails')
assert_equal 'heartcombo', RailsLongtail::Gems.extract_github_org('https://github.com/heartcombo/devise/tree/main')
assert_nil RailsLongtail::Gems.extract_github_org('https://example.com/')
end
def test_ingest_writes_gem_maintainer_signals
VCR.use_cassette('gems_top_1000') do
RailsLongtail::Gems.ingest(@db, pages: 1)
end
count = @db.execute("SELECT COUNT(*) FROM signals WHERE source = 'gem-maintainer'").first.first
assert count.positive?
end
end
Step 2: Run test to verify failure.
Step 3: Implement Gems
Create tooling/rails_longtail/lib/rails_longtail/gems.rb:
# frozen_string_literal: true
require 'httparty'
module RailsLongtail
module Gems
LIST_URL = 'https://rubygems.org/api/v1/gems.json'
GEM_URL = 'https://rubygems.org/api/v1/gems/%s.json'
GH_ORG_RX = %r{github\.com/([a-z0-9][-a-z0-9]*)/}i
def self.ingest(db, pages: 10)
run_id = SourceRun.start(db, 'gem-maintainer')
added = 0
(1..pages).each do |page|
list = HTTParty.get("#{LIST_URL}?page=#{page}").parsed_response
next unless list.is_a?(Array)
list.each do |gem|
info = HTTParty.get(format(GEM_URL, gem['name'])).parsed_response
next unless info.is_a?(Hash)
source = info['source_code_uri'] || info['homepage_uri'] || ''
org = extract_github_org(source)
next unless org
next if personal_account?(org)
homepage = info['homepage_uri']
next unless homepage && !homepage.empty?
domain = URI.parse(homepage).host&.sub(/^www\./, '')
next unless domain && domain.include?('.')
company_id = Company.upsert_by_domain(
db,
domain: domain.downcase,
name: info['name'].split(/[_-]/).first.capitalize,
github_org: org,
website_url: homepage
)
Signal.insert(
db,
company_id: company_id,
source: 'gem-maintainer',
signal_type: 'gem_maintainer',
signal_date: (Date.parse(info['version_created_at']) rescue nil)&.to_s,
evidence_url: "https://rubygems.org/gems/#{info['name']}",
raw_text: "Maintains gem: #{info['name']} (v#{info['version']})"
)
added += 1
sleep 0.2
rescue URI::InvalidURIError
next
end
end
SourceRun.finish(db, run_id, rows_added: added)
added
end
def self.extract_github_org(url)
m = url.to_s.match(GH_ORG_RX)
m && m[1].downcase
end
def self.personal_account?(org)
# Quick heuristic: GitHub user vs org is checked by fetching /users/{org}
# For offline tests we just check a small denylist here.
%w[dhh tenderlove rafaelfranca fxn].include?(org)
end
end
end
Add require to lib/rails_longtail.rb.
Step 4: Record VCR cassette (only pages: 1, ~100 gems).
Step 5: Run tests to verify green.
Step 6: Write ingest_gems binary
Create tooling/rails_longtail/bin/ingest_gems:
#!/usr/bin/env ruby
# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'rails_longtail'
pages = Integer(ENV.fetch('PAGES', '10'))
db = RailsLongtail::DB.open
added = RailsLongtail::Gems.ingest(db, pages: pages)
puts "gems: #{added} signals added (#{pages} pages of top gems)"
db.close
Make executable.
git add tooling/rails_longtail/lib/rails_longtail/gems.rb \
tooling/rails_longtail/bin/ingest_gems tooling/rails_longtail/test/gems_test.rb \
tooling/rails_longtail/test/fixtures tooling/rails_longtail/lib/rails_longtail.rb
git commit -m "add gem maintainer org ingest"
Files:
tooling/rails_longtail/docs/vertical_definitions.mdtooling/rails_longtail/docs/business_model_taxonomy.mdtooling/rails_longtail/docs/classification_prompt.mdtooling/rails_longtail/docs/classification_runbook.mdGoal: All prompt context and operator instructions the Sonnet subagent dispatch needs. Each file is executable-by-human; no Ruby changes.
Content (short, bullet-per-vertical with examples):
# Ten Locked Verticals
Companies are classified into exactly one of these. Pick the dominant vertical of the product the company sells.
- **Healthcare** — EHR, clinical, biotech tooling, patient care, telemedicine, pharma, health-insurance-tech.
Examples: Flatiron Health, Doxy.me, Benchling.
- **BFSI** — banks, lenders, brokerages, insurance, payments, fintech. Anything whose product moves or prices money.
Examples: Chime, Betterment, Coinbase, Plaid.
- **Retail / E-commerce** — online retailers, marketplaces of physical goods, D2C, fulfillment.
Examples: Shopify, Etsy, Gumroad, Stitch Fix.
- **IT / Telecom** — cloud infra, developer tools, telco, SaaS platforms whose customer is IT/dev. Default bucket for dev-tools companies without a vertical.
Examples: GitLab, Zendesk, Basecamp, Honeybadger.
- **Manufacturing / Industry 4.0** — ERP, MES, supply-chain, industrial IoT, CAD/PLM.
Examples: Tulip, Bright Machines.
- **Media** — publishing, streaming, news, social, entertainment.
Examples: Basecamp HEY, The New York Times, Vimeo.
- **Automotive** — OEM, parts, auto-finance, auto-marketplaces, fleet tech.
Examples: Carvana, Recurrent.
- **Cybersecurity** — security vendors, SOC tooling, identity, compliance.
Examples: HackerOne, Snyk, JumpCloud.
- **Education** — K-12, higher-ed, bootcamps, corporate L&D, edtech.
Examples: Code.org, Handshake, Coursera.
- **Energy** — utilities, solar, grid, oil & gas, energy marketplaces.
Examples: Aurora Solar, Arcadia.
Content:
# Business-Model Taxonomy (7 values)
- **marketplace** — two-sided. Takes a cut of transactions between buyers and sellers. (Etsy, Airbnb)
- **b2b_saas** — sells software to other companies via subscription. (GitLab, Zendesk)
- **dev_tools** — sells to developers specifically. (Honeybadger, Sidekiq Enterprise)
- **vertical_saas** — b2b_saas focused on one industry. (Benchling for biotech, Procore for construction)
- **internal_tools** — builds software the company uses itself; Rails powers ops/admin, not the customer product. (Shopify admin, Basecamp's internal tools)
- **media** — ad- or subscription-supported content. (NYT, Vimeo)
- **consumer** — direct-to-consumer app or service. (Gumroad buyer experience, Notion-ish consumer apps)
Content:
# Sonnet Subagent Classification Prompt
You will classify a batch of up to 50 companies into one of ten verticals and one of seven business models, then report a confidence score.
## Input format
You receive a JSON array. Each entry:
```json
{
"company_id": 123,
"name": "Acme",
"domain": "acme.com",
"signals": [
{"source": "github", "signal_type": "code", "signal_date": "2025-11-03", "raw_text": "gem \"rails\""},
{"source": "hn-hiring", "signal_type": "job_post", "signal_date": "2026-01-01", "raw_text": "Acme | Senior Rails Engineer | acme.com sells widgets to healthcare clinics"}
]
}
(Paste contents of vertical_definitions.md here.)
(Paste contents of business_model_taxonomy.md here.)
Return a JSON array, one object per input company, same order:
[
{
"company_id": 123,
"primary_vertical": "Healthcare",
"business_model": "vertical_saas",
"confidence": 0.78,
"notes": "Hiring post states 'widgets to healthcare clinics'; b2b vertical software for clinics."
}
]
Rules:
primary_vertical MUST be one of: Healthcare, BFSI, Retail/E-commerce, IT/Telecom, Manufacturing/Industry 4.0, Media, Automotive, Cybersecurity, Education, Energy.business_model MUST be one of: marketplace, b2b_saas, dev_tools, vertical_saas, internal_tools, media, consumer.confidence is 0.0-1.0. Use 0.9+ only with explicit industry language in signals.notes is one sentence citing evidence.confidence should drop accordingly.Write output as JSONL (one JSON object per line) to a file named classify_batch_<N>.jsonl in tooling/rails_longtail/data/classifications/.
- [ ] **Step 4: Write classification_runbook.md**
Content:
```markdown
# Classification Runbook (Phase 1)
## Prerequisites
- All ingest scripts have been run at least once.
- `data/rails_longtail.sqlite` has >500 rows in `companies`.
## Step 1: Export unclassified companies to JSON batches
```bash
cd tooling/rails_longtail
bin/export_classify_batches
This creates data/classify_input/batch_<N>.json, 50 companies per file. Each entry has company_id, name, domain, and up to 5 most-recent signal rows.
From a Claude Code session at the repo root, for each batch file:
Use the Agent tool with:
subagent_type: general-purposemodel: sonnetprompt: contents of docs/classification_prompt.md followed by “Here is the input batch:” and the JSON from data/classify_input/batch_<N>.json.data/classifications/batch_<N>.jsonl.Fan out multiple subagents concurrently (single message, multiple Agent tool calls).
bin/merge_classifications
This reads all data/classifications/*.jsonl, validates each row, and inserts into the classifications table.
bin/compute_long_tail_fit
bin/flag_audit
Open the audit view in any SQLite client:
SELECT c.name, c.domain, cl.primary_vertical, cl.business_model, cl.confidence, cl.llm_notes
FROM classifications cl JOIN companies c ON c.id = cl.company_id
WHERE cl.audited = 0 AND (cl.confidence < 0.6 OR cl.company_id IN (SELECT company_id FROM audit_queue));
For each row, Rick sets audit_verdict (‘agree’ | ‘disagree’ | ‘unclear’) and audited = 1. Disagreements are re-classified by editing primary_vertical and business_model directly; set classified_by = 'human:rick'.
- [ ] **Step 5: Commit**
```bash
git add tooling/rails_longtail/docs
git commit -m "add classification prompt, taxonomy, and runbook"
Files:
tooling/rails_longtail/lib/rails_longtail/classification.rbtooling/rails_longtail/bin/merge_classificationstooling/rails_longtail/bin/export_classify_batchesCreate: tooling/rails_longtail/test/classification_test.rb
Create tooling/rails_longtail/test/classification_test.rb:
# frozen_string_literal: true
require 'test_helper'
require 'tmpdir'
require 'fileutils'
require 'json'
class ClassificationTest < Minitest::Test
def setup
@tmp = Dir.mktmpdir
@db = RailsLongtail::DB.open(File.join(@tmp, 'test.sqlite'))
@company_id = RailsLongtail::Company.upsert_by_domain(@db, domain: 'acme.com', name: 'Acme')
end
def teardown
@db.close
FileUtils.rm_rf(@tmp)
end
def test_merge_inserts_classifications
jsonl = [
{ company_id: @company_id, primary_vertical: 'Healthcare',
business_model: 'vertical_saas', confidence: 0.82, notes: 'clinical EHR' }
].map(&:to_json).join("\n")
path = File.join(@tmp, 'batch_1.jsonl')
File.write(path, jsonl)
inserted = RailsLongtail::Classification.merge_file(@db, path, classified_by: 'claude-sonnet-4-6')
assert_equal 1, inserted
row = @db.execute('SELECT primary_vertical, business_model, confidence FROM classifications WHERE company_id = ?', @company_id).first
assert_equal ['Healthcare', 'vertical_saas', 0.82], row
end
def test_merge_is_idempotent
jsonl = [{ company_id: @company_id, primary_vertical: 'Healthcare', business_model: 'vertical_saas', confidence: 0.82, notes: 'ok' }].map(&:to_json).join("\n")
path = File.join(@tmp, 'batch_1.jsonl')
File.write(path, jsonl)
RailsLongtail::Classification.merge_file(@db, path, classified_by: 'claude-sonnet-4-6')
RailsLongtail::Classification.merge_file(@db, path, classified_by: 'claude-sonnet-4-6')
count = @db.execute('SELECT COUNT(*) FROM classifications WHERE company_id = ?', @company_id).first.first
assert_equal 1, count
end
def test_merge_rejects_bad_vertical
jsonl = [{ company_id: @company_id, primary_vertical: 'Construction', business_model: 'b2b_saas', confidence: 0.7, notes: 'bad label' }].map(&:to_json).join("\n")
path = File.join(@tmp, 'batch_1.jsonl')
File.write(path, jsonl)
assert_raises(RailsLongtail::Classification::InvalidRow) do
RailsLongtail::Classification.merge_file(@db, path, classified_by: 'claude-sonnet-4-6')
end
end
end
Step 2: Run test to verify failure.
Step 3: Implement Classification
Create tooling/rails_longtail/lib/rails_longtail/classification.rb:
# frozen_string_literal: true
require 'json'
module RailsLongtail
module Classification
class InvalidRow < StandardError; end
VERTICALS = [
'Healthcare', 'BFSI', 'Retail/E-commerce', 'IT/Telecom',
'Manufacturing/Industry 4.0', 'Media', 'Automotive',
'Cybersecurity', 'Education', 'Energy'
].freeze
MODELS = %w[marketplace b2b_saas dev_tools vertical_saas internal_tools media consumer].freeze
def self.merge_file(db, path, classified_by:)
inserted = 0
File.foreach(path) do |line|
line = line.strip
next if line.empty?
row = JSON.parse(line)
validate!(row)
result = db.execute(
<<~SQL,
INSERT INTO classifications
(company_id, primary_vertical, business_model, confidence, llm_notes, classified_by)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(company_id) DO NOTHING
SQL
[
row.fetch('company_id'), row.fetch('primary_vertical'),
row.fetch('business_model'), row.fetch('confidence'),
row['notes'], classified_by
]
)
inserted += db.changes if db.changes.positive?
end
inserted
end
def self.validate!(row)
raise InvalidRow, "missing company_id in #{row.inspect}" unless row['company_id']
raise InvalidRow, "bad vertical: #{row['primary_vertical']}" unless VERTICALS.include?(row['primary_vertical'])
raise InvalidRow, "bad model: #{row['business_model']}" unless MODELS.include?(row['business_model'])
raise InvalidRow, "bad confidence: #{row['confidence']}" unless row['confidence'].is_a?(Numeric) && (0..1).cover?(row['confidence'])
end
end
end
Add require to lib/rails_longtail.rb.
Step 4: Run tests to verify green.
Step 5: Write merge_classifications binary
Create tooling/rails_longtail/bin/merge_classifications:
#!/usr/bin/env ruby
# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'rails_longtail'
dir = File.expand_path('../data/classifications', __dir__)
Dir.glob(File.join(dir, '*.jsonl')).sort.each do |path|
db = RailsLongtail::DB.open
n = RailsLongtail::Classification.merge_file(db, path, classified_by: 'claude-sonnet-4-6')
db.close
puts "#{File.basename(path)}: #{n} rows"
end
Create tooling/rails_longtail/bin/export_classify_batches:
#!/usr/bin/env ruby
# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'rails_longtail'
require 'json'
require 'fileutils'
BATCH_SIZE = 50
out_dir = File.expand_path('../data/classify_input', __dir__)
FileUtils.mkdir_p(out_dir)
db = RailsLongtail::DB.open
rows = db.execute(<<~SQL)
SELECT c.id, c.name, c.domain
FROM companies c
LEFT JOIN classifications cl ON cl.company_id = c.id
WHERE cl.id IS NULL
ORDER BY c.id
SQL
rows.each_slice(BATCH_SIZE).with_index(1) do |slice, idx|
entries = slice.map do |cid, name, domain|
signals = db.execute(
'SELECT source, signal_type, signal_date, raw_text FROM signals WHERE company_id = ? ORDER BY captured_at DESC LIMIT 5',
[cid]
).map { |s, st, sd, rt| { source: s, signal_type: st, signal_date: sd, raw_text: rt } }
{ company_id: cid, name: name, domain: domain, signals: signals }
end
path = File.join(out_dir, "batch_#{idx}.json")
File.write(path, JSON.pretty_generate(entries))
end
db.close
puts "wrote #{Dir.glob(File.join(out_dir, 'batch_*.json')).size} batches to #{out_dir}"
Make both executable and commit.
Files:
tooling/rails_longtail/lib/rails_longtail/long_tail_fit.rbtooling/rails_longtail/lib/rails_longtail/audit.rbtooling/rails_longtail/bin/compute_long_tail_fittooling/rails_longtail/bin/flag_audittooling/rails_longtail/test/long_tail_fit_test.rbCreate: tooling/rails_longtail/test/audit_test.rb
Create tooling/rails_longtail/test/long_tail_fit_test.rb:
# frozen_string_literal: true
require 'test_helper'
require 'tmpdir'
require 'fileutils'
class LongTailFitTest < Minitest::Test
def setup
@tmp = Dir.mktmpdir
@db = RailsLongtail::DB.open(File.join(@tmp, 'test.sqlite'))
end
def teardown
@db.close
FileUtils.rm_rf(@tmp)
end
def test_marks_small_non_sponsor_as_fit
id = RailsLongtail::Company.upsert_by_domain(@db, domain: 'small.com', name: 'Small', headcount_tier: 'S')
RailsLongtail::LongTailFit.compute(@db)
fit = @db.execute('SELECT long_tail_fit FROM companies WHERE id = ?', id).first.first
assert_equal 1, fit
end
def test_marks_conference_sponsor_as_not_fit
id = RailsLongtail::Company.upsert_by_domain(@db, domain: 'bigco.com', name: 'BigCo', headcount_tier: 'S')
RailsLongtail::Signal.insert(@db, company_id: id, source: 'conf-sponsors', signal_type: 'sponsor')
RailsLongtail::LongTailFit.compute(@db)
fit = @db.execute('SELECT long_tail_fit FROM companies WHERE id = ?', id).first.first
assert_equal 0, fit
end
def test_marks_large_headcount_as_not_fit
id = RailsLongtail::Company.upsert_by_domain(@db, domain: 'large.com', name: 'Large', headcount_tier: 'L')
RailsLongtail::LongTailFit.compute(@db)
fit = @db.execute('SELECT long_tail_fit FROM companies WHERE id = ?', id).first.first
assert_equal 0, fit
end
def test_leaves_unknown_headcount_as_null
id = RailsLongtail::Company.upsert_by_domain(@db, domain: 'unknown.com', name: 'Unknown')
RailsLongtail::LongTailFit.compute(@db)
fit = @db.execute('SELECT long_tail_fit FROM companies WHERE id = ?', id).first.first
assert_nil fit
end
end
Step 2: Run test to verify failure.
Step 3: Implement LongTailFit
Create tooling/rails_longtail/lib/rails_longtail/long_tail_fit.rb:
# frozen_string_literal: true
module RailsLongtail
module LongTailFit
SMALL_TIERS = %w[XS S M].freeze
def self.compute(db)
db.execute(<<~SQL)
UPDATE companies
SET long_tail_fit = 1, updated_at = CURRENT_TIMESTAMP
WHERE headcount_tier IN (#{SMALL_TIERS.map { |t| "'#{t}'" }.join(',')})
AND id NOT IN (
SELECT DISTINCT company_id FROM signals WHERE source = 'conf-sponsors'
)
SQL
db.execute(<<~SQL)
UPDATE companies
SET long_tail_fit = 0, updated_at = CURRENT_TIMESTAMP
WHERE (headcount_tier NOT IN (#{SMALL_TIERS.map { |t| "'#{t}'" }.join(',')})
OR id IN (SELECT DISTINCT company_id FROM signals WHERE source = 'conf-sponsors'))
AND headcount_tier IS NOT NULL
SQL
end
end
end
Add require_relative 'rails_longtail/long_tail_fit' to lib/rails_longtail.rb; run bundle exec rake test TEST=test/long_tail_fit_test.rb and confirm green.
Create tooling/rails_longtail/bin/compute_long_tail_fit:
#!/usr/bin/env ruby
# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'rails_longtail'
db = RailsLongtail::DB.open
RailsLongtail::LongTailFit.compute(db)
updated = db.execute('SELECT COUNT(*) FROM companies WHERE long_tail_fit IS NOT NULL').first.first
puts "long_tail_fit computed on #{updated} companies"
db.close
Make executable: chmod +x tooling/rails_longtail/bin/compute_long_tail_fit.
Create tooling/rails_longtail/test/audit_test.rb:
# frozen_string_literal: true
require 'test_helper'
require 'tmpdir'
require 'fileutils'
class AuditTest < Minitest::Test
def setup
@tmp = Dir.mktmpdir
@db = RailsLongtail::DB.open(File.join(@tmp, 'test.sqlite'))
end
def teardown
@db.close
FileUtils.rm_rf(@tmp)
end
def test_flag_audit_marks_low_confidence
id = RailsLongtail::Company.upsert_by_domain(@db, domain: 'a.com', name: 'A')
@db.execute('INSERT INTO classifications (company_id, primary_vertical, business_model, confidence, classified_by) VALUES (?, ?, ?, ?, ?)',
[id, 'Healthcare', 'vertical_saas', 0.4, 'claude-sonnet-4-6'])
RailsLongtail::Audit.flag(@db)
queued = @db.execute("SELECT company_id FROM audit_queue WHERE company_id = ?", id).first
refute_nil queued
end
def test_flag_audit_includes_web_search_sourced
id = RailsLongtail::Company.upsert_by_domain(@db, domain: 'b.com', name: 'B')
RailsLongtail::Signal.insert(@db, company_id: id, source: 'web-search', signal_type: 'web_mention')
@db.execute('INSERT INTO classifications (company_id, primary_vertical, business_model, confidence, classified_by) VALUES (?, ?, ?, ?, ?)',
[id, 'Healthcare', 'vertical_saas', 0.85, 'claude-sonnet-4-6'])
RailsLongtail::Audit.flag(@db, web_search_audit_rate: 1.0)
queued = @db.execute("SELECT company_id FROM audit_queue WHERE company_id = ?", id).first
refute_nil queued
end
end
Step 5: Run test to verify failure.
Step 6: Implement Audit
Create tooling/rails_longtail/lib/rails_longtail/audit.rb:
# frozen_string_literal: true
module RailsLongtail
module Audit
def self.flag(db, random_sample_rate: 0.05, web_search_audit_rate: 0.175)
db.execute(<<~SQL)
CREATE TABLE IF NOT EXISTS audit_queue (
company_id INTEGER PRIMARY KEY REFERENCES companies(id),
reason TEXT,
queued_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
SQL
# Low confidence
db.execute(<<~SQL)
INSERT OR IGNORE INTO audit_queue (company_id, reason)
SELECT company_id, 'low_confidence' FROM classifications WHERE confidence < 0.6
SQL
# Random sample per vertical (at least 5%)
db.execute('SELECT DISTINCT primary_vertical FROM classifications WHERE primary_vertical IS NOT NULL').each do |(vertical)|
ids = db.execute('SELECT company_id FROM classifications WHERE primary_vertical = ? ORDER BY RANDOM() LIMIT ?',
[vertical, [5, (count_for(db, vertical) * random_sample_rate).ceil].max])
ids.each do |(cid)|
db.execute('INSERT OR IGNORE INTO audit_queue (company_id, reason) VALUES (?, ?)', [cid, "random_sample_#{vertical}"])
end
end
# Web-search sourced
ws_ids = db.execute(<<~SQL).flatten
SELECT DISTINCT s.company_id
FROM signals s JOIN classifications cl ON cl.company_id = s.company_id
WHERE s.source = 'web-search'
SQL
sampled = ws_ids.select { |_| rand < web_search_audit_rate }
sampled.each do |cid|
db.execute('INSERT OR IGNORE INTO audit_queue (company_id, reason) VALUES (?, ?)', [cid, 'web_search_source'])
end
end
def self.count_for(db, vertical)
db.execute('SELECT COUNT(*) FROM classifications WHERE primary_vertical = ?', [vertical]).first.first
end
end
end
Add require_relative 'rails_longtail/audit' to lib/rails_longtail.rb; run bundle exec rake test TEST=test/audit_test.rb and confirm green.
Create tooling/rails_longtail/bin/flag_audit:
#!/usr/bin/env ruby
# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'rails_longtail'
db = RailsLongtail::DB.open
RailsLongtail::Audit.flag(db)
queued = db.execute('SELECT COUNT(*) FROM audit_queue').first.first
puts "audit_queue size: #{queued}"
db.close
Make executable.
git add tooling/rails_longtail/lib/rails_longtail/long_tail_fit.rb \
tooling/rails_longtail/lib/rails_longtail/audit.rb \
tooling/rails_longtail/bin/compute_long_tail_fit tooling/rails_longtail/bin/flag_audit \
tooling/rails_longtail/test/long_tail_fit_test.rb tooling/rails_longtail/test/audit_test.rb \
tooling/rails_longtail/lib/rails_longtail.rb
git commit -m "add long-tail-fit computation and audit flagging"
Files:
tooling/rails_longtail/lib/rails_longtail/scorecard.rbtooling/rails_longtail/bin/run_scorecardtooling/rails_longtail/bin/export_csvstooling/rails_longtail/test/scorecard_test.rbScope note: The spec’s five scorecard dimensions are Rails density, Activity, Long-tail fit, Warm-network overlap, and Competitive density. The first three are computed from the SQLite data and printed by bin/run_scorecard. The last two (warm-network overlap, competitive density) are manual overlays Rick adds by hand in RAILS_LONGTAIL_MAP.md during Phase 2 — they pull from RICK_GORMAN_PROFILE.md and Rick’s judgment about consultancy competition, neither of which this pipeline ingests. Do NOT try to automate them.
Create tooling/rails_longtail/test/scorecard_test.rb:
# frozen_string_literal: true
require 'test_helper'
require 'tmpdir'
require 'fileutils'
class ScorecardTest < Minitest::Test
def setup
@tmp = Dir.mktmpdir
@db = RailsLongtail::DB.open(File.join(@tmp, 'test.sqlite'))
3.times do |i|
id = RailsLongtail::Company.upsert_by_domain(@db, domain: "h#{i}.com", name: "H#{i}", headcount_tier: 'S')
@db.execute('INSERT INTO classifications (company_id, primary_vertical, business_model, confidence, classified_by) VALUES (?, ?, ?, ?, ?)',
[id, 'Healthcare', 'vertical_saas', 0.8, 'claude-sonnet-4-6'])
RailsLongtail::Signal.insert(@db, company_id: id, source: 'hn-hiring', signal_type: 'job_post', signal_date: Date.today.to_s)
end
RailsLongtail::LongTailFit.compute(@db)
end
def teardown
@db.close
FileUtils.rm_rf(@tmp)
end
def test_scorecard_reports_rails_density_and_activity
row = RailsLongtail::Scorecard.rows(@db).find { |r| r[:vertical] == 'Healthcare' }
assert_equal 3, row[:rails_density]
assert_in_delta 1.0, row[:activity_rate], 0.001
assert_in_delta 1.0, row[:long_tail_fit_rate], 0.001
end
def test_overlap_rates_counts_source_intersection_per_vertical
only_github_id = RailsLongtail::Company.upsert_by_domain(@db, domain: 'ghonly.com', name: 'GHOnly', headcount_tier: 'S')
@db.execute('INSERT INTO classifications (company_id, primary_vertical, business_model, confidence, classified_by) VALUES (?, ?, ?, ?, ?)',
[only_github_id, 'BFSI', 'b2b_saas', 0.8, 'claude-sonnet-4-6'])
RailsLongtail::Signal.insert(@db, company_id: only_github_id, source: 'github', signal_type: 'code')
both_id = RailsLongtail::Company.upsert_by_domain(@db, domain: 'both.com', name: 'Both', headcount_tier: 'S')
@db.execute('INSERT INTO classifications (company_id, primary_vertical, business_model, confidence, classified_by) VALUES (?, ?, ?, ?, ?)',
[both_id, 'BFSI', 'b2b_saas', 0.8, 'claude-sonnet-4-6'])
RailsLongtail::Signal.insert(@db, company_id: both_id, source: 'github', signal_type: 'code')
RailsLongtail::Signal.insert(@db, company_id: both_id, source: 'hn-hiring', signal_type: 'job_post')
bfsi = RailsLongtail::Scorecard.overlap_rates(@db).find { |r| r[:vertical] == 'BFSI' }
assert_equal 1, bfsi[:github_only]
assert_equal 0, bfsi[:jobs_only]
assert_equal 1, bfsi[:both]
assert_in_delta 0.5, bfsi[:overlap_rate], 0.001
end
end
Step 2: Run test to verify failure.
Step 3: Implement Scorecard
Create tooling/rails_longtail/lib/rails_longtail/scorecard.rb:
# frozen_string_literal: true
module RailsLongtail
module Scorecard
def self.rows(db)
verticals = Classification::VERTICALS
verticals.map do |v|
density = db.execute('SELECT COUNT(*) FROM classifications WHERE primary_vertical = ?', [v]).first.first
next {
vertical: v,
rails_density: 0,
activity_rate: 0.0,
long_tail_fit_rate: 0.0
} if density.zero?
active = db.execute(<<~SQL, [v]).first.first
SELECT COUNT(DISTINCT cl.company_id)
FROM classifications cl
JOIN signals s ON s.company_id = cl.company_id
WHERE cl.primary_vertical = ?
AND s.signal_type = 'job_post'
AND s.signal_date >= date('now','-365 day')
SQL
ltf = db.execute(<<~SQL, [v]).first.first
SELECT COUNT(*)
FROM classifications cl
JOIN companies c ON c.id = cl.company_id
WHERE cl.primary_vertical = ? AND c.long_tail_fit = 1
SQL
{
vertical: v,
rails_density: density,
activity_rate: (active.to_f / density).round(3),
long_tail_fit_rate: (ltf.to_f / density).round(3)
}
end
end
def self.print(db, out = $stdout)
fmt = "%-32s %10s %12s %14s\n"
out.printf(fmt, 'Vertical', 'Density', 'Activity', 'LongTailFit')
rows(db).each { |r| out.printf(fmt, r[:vertical], r[:rails_density], r[:activity_rate], r[:long_tail_fit_rate]) }
end
# Capture-recapture overlap between github and job-board sources, per vertical.
# Reported for spec Section 11 "Verification".
def self.overlap_rates(db)
Classification::VERTICALS.map do |v|
gh_ids = db.execute(<<~SQL, [v]).flatten.to_set
SELECT DISTINCT cl.company_id FROM classifications cl
JOIN signals s ON s.company_id = cl.company_id
WHERE cl.primary_vertical = ? AND s.source = 'github'
SQL
job_ids = db.execute(<<~SQL, [v]).flatten.to_set
SELECT DISTINCT cl.company_id FROM classifications cl
JOIN signals s ON s.company_id = cl.company_id
WHERE cl.primary_vertical = ? AND s.source IN ('hn-hiring','wwr','remoteok','board-dorks')
SQL
union = (gh_ids | job_ids).size
intersection = (gh_ids & job_ids).size
rate = union.zero? ? 0.0 : (intersection.to_f / union).round(3)
{ vertical: v, github_only: (gh_ids - job_ids).size, jobs_only: (job_ids - gh_ids).size, both: intersection, overlap_rate: rate }
end
end
end
end
Add require 'set' to the top of scorecard.rb (above the module).
Add require; run test green.
Create tooling/rails_longtail/bin/run_scorecard:
#!/usr/bin/env ruby
# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'rails_longtail'
db = RailsLongtail::DB.open
RailsLongtail::Scorecard.print(db)
puts ''
puts 'Capture-recapture overlap (github vs job boards):'
printf("%-32s %10s %10s %10s %10s\n", 'Vertical', 'GH only', 'Jobs only', 'Both', 'Overlap')
RailsLongtail::Scorecard.overlap_rates(db).each do |r|
printf("%-32s %10d %10d %10d %10.3f\n", r[:vertical], r[:github_only], r[:jobs_only], r[:both], r[:overlap_rate])
end
db.close
Create tooling/rails_longtail/bin/export_csvs:
#!/usr/bin/env ruby
# frozen_string_literal: true
require 'csv'
require 'fileutils'
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'rails_longtail'
out_dir = File.expand_path('../exports', __dir__)
FileUtils.mkdir_p(out_dir)
db = RailsLongtail::DB.open
CSV.open(File.join(out_dir, 'shallow_by_vertical.csv'), 'w') do |csv|
csv << %w[vertical company_id domain name business_model headcount_tier long_tail_fit confidence]
db.execute(<<~SQL).each do |row|
SELECT cl.primary_vertical, c.id, c.domain, c.name, cl.business_model, c.headcount_tier, c.long_tail_fit, cl.confidence
FROM classifications cl JOIN companies c ON c.id = cl.company_id
WHERE cl.primary_vertical IS NOT NULL
ORDER BY cl.primary_vertical, cl.confidence DESC
SQL
csv << row
end
end
puts "wrote exports/shallow_by_vertical.csv"
db.close
Make executable; commit.
Files:
tooling/rails_longtail/lib/rails_longtail/web_search.rbtooling/rails_longtail/bin/merge_web_searchtooling/rails_longtail/test/web_search_test.rbtooling/rails_longtail/docs/web_search_runbook.mdtooling/rails_longtail/docs/phase3_runbook.mdCreate: tooling/rails_longtail/README.md
Create tooling/rails_longtail/test/web_search_test.rb:
# frozen_string_literal: true
require 'test_helper'
require 'tmpdir'
require 'fileutils'
require 'json'
class WebSearchTest < Minitest::Test
def setup
@tmp = Dir.mktmpdir
@db = RailsLongtail::DB.open(File.join(@tmp, 'test.sqlite'))
end
def teardown
@db.close
FileUtils.rm_rf(@tmp)
end
def test_merge_upserts_company_and_appends_web_mention_signal
jsonl = [
{ domain: 'acme.com', name: 'Acme', evidence_url: 'https://example.com/post', raw_text: 'Built with Rails for healthcare' }
].map(&:to_json).join("\n")
path = File.join(@tmp, 'healthcare.jsonl')
File.write(path, jsonl)
added = RailsLongtail::WebSearch.merge_file(@db, path)
assert_equal 1, added
row = @db.execute("SELECT c.domain, s.source, s.signal_type FROM signals s JOIN companies c ON c.id=s.company_id").first
assert_equal ['acme.com', 'web-search', 'web_mention'], row
end
def test_merge_skips_malformed_rows
File.write(File.join(@tmp, 'bad.jsonl'), "not-json\n{\"domain\":\"ok.com\",\"name\":\"OK\",\"evidence_url\":\"https://x\"}\n")
added = RailsLongtail::WebSearch.merge_file(@db, File.join(@tmp, 'bad.jsonl'))
assert_equal 1, added
end
end
Run: bundle exec rake test TEST=test/web_search_test.rb
Expected: FAIL on uninitialized constant RailsLongtail::WebSearch.
Create tooling/rails_longtail/lib/rails_longtail/web_search.rb:
# frozen_string_literal: true
require 'json'
module RailsLongtail
module WebSearch
def self.merge_file(db, path)
added = 0
File.foreach(path) do |line|
line = line.strip
next if line.empty?
begin
row = JSON.parse(line)
rescue JSON::ParserError
next
end
domain = row['domain'].to_s.strip.downcase
name = row['name'].to_s.strip
next if domain.empty? || name.empty?
company_id = Company.upsert_by_domain(db, domain: domain, name: name)
Signal.insert(
db,
company_id: company_id,
source: 'web-search',
signal_type: 'web_mention',
evidence_url: row['evidence_url'],
raw_text: row['raw_text'].to_s[0, 500]
)
added += 1
end
added
end
def self.merge_dir(db, dir)
total = 0
Dir.glob(File.join(dir, '*.jsonl')).sort.each do |path|
total += merge_file(db, path)
end
total
end
end
end
Add require_relative 'rails_longtail/web_search' to lib/rails_longtail.rb.
Run: bundle exec rake test TEST=test/web_search_test.rb
Expected: 2 tests, 0 failures.
Create tooling/rails_longtail/bin/merge_web_search:
#!/usr/bin/env ruby
# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'rails_longtail'
dir = File.expand_path('../data/web_search', __dir__)
db = RailsLongtail::DB.open
run_id = RailsLongtail::SourceRun.start(db, 'web-search')
added = RailsLongtail::WebSearch.merge_dir(db, dir)
RailsLongtail::SourceRun.finish(db, run_id, rows_added: added, notes: "merged from #{dir}")
puts "web-search: #{added} signals merged"
db.close
Make executable.
Content:
# Web-Search Ingest Runbook (Source #6)
Web search is NOT a Ruby script — it's run from Claude Code using the WebSearch tool dispatched to Sonnet subagents. Each subagent returns JSON company rows that a merge step (below) inserts as signals.
## Query batch (edit as needed)
For each of the 10 verticals, dispatch one Sonnet subagent with a prompt like:
> You will run these Google/DuckDuckGo queries and extract every distinct company mentioned as a Rails user. Output one JSON line per company to `data/web_search/<vertical>.jsonl`.
>
> Queries (substitute `<V>` with the vertical keyword, e.g., `healthcare`, `fintech`, `auto`):
> 1. `"built with Rails" <V> site:*.com -github.com`
> 2. `"our Rails app" <V> "engineering blog"`
> 3. `"Ruby on Rails" <V> "case study" site:thoughtbot.com OR site:planetargon.com OR site:testdouble.com`
> 4. `"Ruby on Rails" <V> podcast guest`
> 5. `"Ruby on Rails" <V> site:dev.to`
>
> For each hit that mentions a specific company + Rails usage, output:
> ```json
> {"domain": "acme.com", "name": "Acme", "evidence_url": "https://...", "raw_text": "...first 500 chars..."}
> ```
Fan out all 10 subagents in parallel (single message, multiple Agent tool calls).
## Merge step
Run `bin/merge_web_search` to walk `data/web_search/*.jsonl` and upsert each row into `companies` + `signals` with `source='web-search'`, `signal_type='web_mention'`.
## Audit
This source is the noisiest. When you run `bin/flag_audit` it samples 15-20% of web-search-sourced classifications for manual audit.
Content:
# Phase 3 Runbook — Deep Dive in Chosen Niche
**Prerequisites:** Phase 2 complete. `RAILS_LONGTAIL_MAP.md` records the chosen niche.
This runbook is a TEMPLATE. Fill in vertical-specific sources at pick time.
## Step 1: Add 2-3 vertical-specific sources
Examples:
- Healthcare vertical-SaaS → HIMSS member directory, KLAS Research vendor lists, medical-tech engineering-blog rings.
- B2B dev-tools → YC dev-tools batch alumni list, StackShare dev-tools category, GitHub Marketplace listings.
Create scraper files under `lib/rails_longtail/deep/<source_name>.rb` following the same upsert pattern as shallow sources. Record signals with a distinct `source` string (e.g., `himss`, `klas`).
## Step 2: Re-run shallow sources with tighter filters
Modify each scraper's query to include niche keywords. Examples for healthcare:
- Github: add `"HIPAA"`, `"FHIR"`, `"EHR"` to search query
- HN Hiring parser: pre-filter comments for `healthcare`, `clinical`, `HIPAA`, `EHR`
- Board dorks: add `"HIPAA"` to the query line
## Step 3: Manual classification
No LLM in this phase. Open the companies table filtered to the chosen vertical:
```sql
SELECT c.name, c.domain, c.long_tail_fit, c.headcount_tier, cl.confidence
FROM classifications cl JOIN companies c ON c.id = cl.company_id
WHERE cl.primary_vertical = '<niche>'
ORDER BY c.long_tail_fit DESC, cl.confidence ASC;
Rick reviews each row, removes false positives, and sets classified_by = 'human:rick' on anything manually verified.
For each surviving company:
"<company name>" site:linkedin.com/company)companies rowbin/export_csvs
# then rename: mv exports/shallow_by_vertical.csv exports/deep_<niche>.csv
500-1,500 curated long-tail rows in one vertical, ready for Phase 4 contact enrichment (separate plan).
- [ ] **Step 3: Write README.md**
Content:
```markdown
# Rails Long-Tail Discovery Pipeline
Discovers Rails-using companies across ten verticals, classifies them, and produces a scorecard to help pick one niche to target for the April 2026 outreach campaign. See spec at `docs/superpowers/specs/2026-04-16-rails-longtail-discovery-design.md`.
## Setup
```bash
cd tooling/rails_longtail
bundle install
export GITHUB_TOKEN=<your GitHub classic PAT with public_repo + read:org scopes>
bin/bootstrap_db
bin/ingest_github
bin/ingest_hn_hiring
bin/ingest_job_feeds
bin/ingest_board_dorks
bin/ingest_rails_foundation
bin/ingest_conferences
bin/ingest_gems
# Web search: see docs/web_search_runbook.md
See docs/classification_runbook.md. Summary:
bin/export_classify_batches
# dispatch Sonnet subagents from Claude Code using docs/classification_prompt.md
bin/merge_classifications
bin/compute_long_tail_fit
bin/flag_audit
bin/run_scorecard
bin/export_csvs
Review exports/shallow_by_vertical.csv + the scorecard output, then update RAILS_LONGTAIL_MAP.md with the chosen niche and rationale.
See docs/phase3_runbook.md. A separate implementation plan is authored at niche-pick time.
bundle exec rake test
data/rails_longtail.sqlite — main DB (gitignored)data/classify_input/ — per-batch JSON input to Sonnet (gitignored)data/classifications/ — per-batch JSONL output from Sonnet (gitignored)data/web_search/ — per-vertical JSONL output from web-search subagents (gitignored)exports/ — CSV exports (gitignored)
```
git add tooling/rails_longtail/lib/rails_longtail/web_search.rb \
tooling/rails_longtail/bin/merge_web_search \
tooling/rails_longtail/test/web_search_test.rb \
tooling/rails_longtail/docs tooling/rails_longtail/README.md \
tooling/rails_longtail/lib/rails_longtail.rb
git commit -m "add web-search merge, runbooks, and README"
After completing all 15 tasks, run this verification:
bundle exec rake test — every test file green.bin/bootstrap_db && bin/ingest_github && bin/ingest_hn_hiring && bin/run_scorecard completes without errors on a clean DB.sqlite3 data/rails_longtail.sqlite ".schema" matches schema.sql.bin/flag_audit creates audit_queue and inserts rows according to the confidence + random-sample + web-search rules.bin/export_csvs writes a well-formed exports/shallow_by_vertical.csv with header row.docs/superpowers/specs/2026-04-16-rails-longtail-discovery-design.md is implemented by at least one task. Any gap → add a task.RAILS_LONGTAIL_MAP.md in the project root after reviewing the scorecard.docs/superpowers/plans/YYYY-MM-DD-rails-longtail-deep-<niche>.md based on docs/phase3_runbook.md.