Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
148164e
Create tables
APRILjcc Jun 23, 2026
28b7038
Delete data/tables
APRILjcc Jun 23, 2026
644a199
Add initial IPI mockup figures and tables
APRILjcc Jun 23, 2026
e727e5c
Create mockup.html
aruship4 Jun 24, 2026
c0fcc32
mockup
aruship4 Jun 26, 2026
5478bf7
fix: resolve path before relative_to in extract-prices
haojian Jun 27, 2026
6406878
feat: trailing-12-month recent-window IPI pipeline
haojian Jun 27, 2026
e87f50e
data: recent trailing-12-month IPI outputs
haojian Jun 27, 2026
c847a8c
docs: log trailing-12mo IPI completion; unblock website plan
haojian Jun 27, 2026
f563a29
feat: CSRankings-style IPI website (monthly, client-side recompute)
haojian Jun 27, 2026
4ee6ada
chore: add deploy-site.sh helper; log gh-pages publish
haojian Jun 27, 2026
173f3fe
revert: take down IPI website frontend + gh-pages deploy
haojian Jun 27, 2026
a066f02
docs: document data.json contract for the IPI website data layer
haojian Jun 27, 2026
b818beb
docs: add step-by-step build guide for plain-JS interactive IPI site
haojian Jun 27, 2026
1f3a0f3
feat: commit self-contained IPI frontend (no external libs)
haojian Jun 30, 2026
72a75fc
docs: update todo — frontend committed, website item narrowed to deploy
haojian Jun 30, 2026
07954af
ci: add GitHub Pages workflow to deploy site/ from mockup
haojian Jun 30, 2026
c7a6597
docs: update todo — Pages workflow added, pending one-time Source set…
haojian Jun 30, 2026
34d9fdd
deploy: move site/ -> docs/ for GitHub Pages branch deploy
haojian Jun 30, 2026
be7d56e
feat: add FAQ & methodology page to the IPI site
haojian Jun 30, 2026
bf5dc2c
style: refresh IPI site aesthetics (cards, type, accent, logo)
haojian Jun 30, 2026
e002c91
feat(site): define IPI under title, side-by-side chart + category sel…
haojian Jun 30, 2026
778ccb7
feat(site): highlight significant month-over-month composite moves
haojian Jun 30, 2026
fb1f806
docs(faq): draft FAQ section with existing + new questions
haojian Jun 30, 2026
d980fff
feat(site): bigger chart, move descriptions, and main+subcategory index
haojian Jun 30, 2026
830adb9
Expand FAQ section with new questions and updates
APRILjcc Jun 30, 2026
a275909
feat(site): full-history quarterly IPI (2020→2026) + freelancer rankings
haojian Jul 3, 2026
7ff3058
feat(site): freelancer explorer — 25/category + per-gig price-over-ti…
haojian Jul 7, 2026
012b7ea
fix(index): gap-tolerant panel chaining + graph quarter-inspector
haojian Jul 7, 2026
bc72b15
feat(site): distinct CVD-safe per-category color palette
haojian Jul 7, 2026
c9584b7
style(site): smaller page gutters + mobile-scrollable FAQ tables
haojian Jul 7, 2026
818d63d
docs: log site gutter/margin change in progress.md
haojian Jul 7, 2026
4847c4c
style(site): center header title/subtitle + drop rank (#) column
haojian Jul 7, 2026
078d0cd
docs(faq): sync draft to 2020Q1→2026Q1 quarterly chart + 6 new questions
haojian Jul 7, 2026
4151a24
feat(site): apply all 21 FAQ questions to live docs/faq.html
haojian Jul 7, 2026
ba8e3dd
style(faq): widen content column 900→1600px to shrink side margins
haojian Jul 7, 2026
7137877
style(faq): center header title and subtitle
haojian Jul 7, 2026
4c20173
feat(site): default to single-category view; add axis units; drop com…
haojian Jul 11, 2026
95a5935
pilot(index): unbalanced-panel methods reveal chain drift in headline…
haojian Jul 11, 2026
a61a91f
feat(site): label IPI units on axis, caption, tooltip & inspector
haojian Jul 11, 2026
1379eb0
feat(site): title the line chart & enlarge category dropdown caret
haojian Jul 12, 2026
fd38079
docs(faq): rewrite answers, merge 21→15 questions, fix stale framing
haojian Jul 12, 2026
7a765bb
docs(faq): sync drafts/sections/faq.md to shipped 15-question page
haojian Jul 12, 2026
f594f03
Update FAQ on gig pricing and data sources
APRILjcc Jul 12, 2026
f13f63d
Update FAQ with category qualification details
APRILjcc Jul 12, 2026
f5dc29a
docs(faq): restore Q6 tables + write out Q8 equations in draft
haojian Jul 12, 2026
d19216a
docs(faq): sync draft Q5/Q7/Q8 into live faq.html
haojian Jul 13, 2026
9a663e9
docs(faq): clarify Q3 above/below-100 reading against the actual chart
haojian Jul 13, 2026
35c3760
Update FAQ on index reading and price changes
APRILjcc Jul 13, 2026
132e9f9
docs(site): add index-point units wherever an IPI level number renders
haojian Jul 13, 2026
fccefeb
docs(site): show "pts" unit on every y-axis gridline, not just the to…
haojian Jul 13, 2026
f096693
docs(progress): log per-tick y-axis units + push
haojian Jul 13, 2026
43005b1
docs(site): sync new FAQ Q3 into live page; default category list to A→Z
haojian Jul 13, 2026
4f9f376
feat(index): add time-dummy (TPD) index; redesign per-quarter inspector
haojian Jul 13, 2026
9fd8337
feat(site): show corrected time-dummy index as a second chart under t…
haojian Jul 13, 2026
705c3ae
docs(site): label corrected-index chart "fixed-effects" (presentation…
haojian Jul 13, 2026
286d9d7
docs(site): separate the fixed-effects chart into its own card; clean…
haojian Jul 13, 2026
f35d0f1
fix(site): restore main-chart content; make fixed-effects chart same …
haojian Jul 13, 2026
348a77d
revert(site): restore original quarter-click percentage labels (QoQ/Y…
haojian Jul 13, 2026
65db931
feat(site): quarter readout shows composite then all 7 categories (A-Z)
haojian Jul 14, 2026
4870b1a
site: composite+7-category quarter readout; units explainer; TOP_N=10…
haojian Jul 14, 2026
a0a5a4e
docs(progress): log mission statement added to site header
haojian Jul 14, 2026
c7b005e
site: condense header into two side-by-side boxes
haojian Jul 14, 2026
ed1071c
validate: price-extraction spot-check + fixed-effects 95% confidence …
haojian Jul 14, 2026
92f60f8
docs(site): surface data-validation result on the fixed-effects chart
haojian Jul 14, 2026
b876ea0
feat(site): fixed-effects chart gets its own quarter dropdown + readout
haojian Jul 14, 2026
7b82973
docs(site): style the fixed-effects quarter dropdown; move validated …
haojian Jul 14, 2026
c93d7cf
docs(site): fixed-effects card as bullets; fold validation into a bullet
haojian Jul 14, 2026
8026970
feat(site): add per-category Gallery page (index trend + featured gig)
haojian Jul 14, 2026
adfdb6c
docs: add professor-facing website + fixed-effects update summary
haojian Jul 14, 2026
2a70cad
docs(site): FAQ Step 5 — plain-language fixed-effects example
haojian Jul 14, 2026
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
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,19 @@ data/cdx-index/*.tmp

# Raw scraped HTML pages
data/pilot/html/
data/pilot/html-recent/
data/pilot/html-recent-test/

# Transient pipeline stdout logs
data/pilot/*.out
runs/**/*.out

# Large pilot manifests (small derived CSVs/summaries are kept)
data/pilot/*-manifest*.tsv

# Scratch / working artifacts (not deliverables)
scratchpad/

# === Python / OS cruft ===
__pycache__/
*.pyc
Expand Down
15 changes: 14 additions & 1 deletion code/09-extract-prices.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,10 @@ def process_file(filepath):
result["year"] = year
result["month"] = month
result["title"] = extract_title(html)
result["file_path"] = str(filepath.relative_to(BASE_DIR))
try:
result["file_path"] = str(Path(filepath).resolve().relative_to(BASE_DIR))
except ValueError:
result["file_path"] = str(filepath)

rating, review_count = extract_rating(html)
result["rating"] = rating if rating is not None else ""
Expand All @@ -303,7 +306,17 @@ def process_file(filepath):


def main():
import argparse
global HTML_DIR, OUTPUT, ERRORS_LOG
ap = argparse.ArgumentParser()
ap.add_argument("--html-dir", type=Path, default=HTML_DIR)
ap.add_argument("--output", type=Path, default=OUTPUT)
ap.add_argument("--errors-log", type=Path, default=ERRORS_LOG)
a = ap.parse_args()
HTML_DIR, OUTPUT, ERRORS_LOG = a.html_dir, a.output, a.errors_log

print("Extracting prices from downloaded HTML...")
print(f" Input: {HTML_DIR}")

html_files = sorted(HTML_DIR.rglob("*.html"))
print(f" Found {len(html_files):,} HTML files")
Expand Down
18 changes: 13 additions & 5 deletions code/12-panel-ipi.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,17 +196,25 @@ def main():
# Initialize at base quarter = 100
index = {base_q: 100.0}

# Chain forward from base
# Chain forward from base. Gap-tolerant: the matched-model relative at a
# quarter is p_t / p_{gig's previous observed quarter}, which already spans
# any gap, so we chain from the most recent quarter already in the index
# rather than requiring the immediately-preceding grid quarter. This keeps a
# category alive across coverage gaps (e.g. translation is empty in 2019Q2–Q4
# right after the base but has 18 solid transitions over 2020Q1–2024Q3; the
# old "prev grid quarter must be in index" rule silently discarded all of it).
forward_qs = [q for q in all_quarters if q >= base_q]
last_q = base_q
for i in range(1, len(forward_qs)):
q = forward_qs[i]
if q in quarter_relatives and len(quarter_relatives[q]) >= 3:
geo_mean = np.exp(np.mean(np.log(quarter_relatives[q])))
prev_q = forward_qs[i-1]
if prev_q in index:
index[q] = index[prev_q] * geo_mean
index[q] = index[last_q] * geo_mean
last_q = q

# Chain backward from base
# Chain backward from base. Kept strict (immediately-preceding grid quarter
# must already be in the index) so pre-base historical levels — which feed
# the paper's peak/start figures — are unchanged by this fix.
backward_qs = [q for q in reversed(all_quarters) if q <= base_q]
for i in range(1, len(backward_qs)):
q = backward_qs[i]
Expand Down
126 changes: 126 additions & 0 deletions code/13-recent-manifest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""
Step 13: Build a recent-window download manifest to extend the IPI into the
trailing 12 months (true "past year").

The 500-seller pilot was sampled for long histories and goes sparse after 2024Q4.
To compute a genuine last-12-months per-category index we need gigs that are
actually archived in the recent quarters. This script selects, per category,
gigs that have:
- >= 2 distinct quarters of coverage in the window (2024Q3 onward), AND
- >= 1 snapshot in the trailing window (2025Q3..2026Q2),
so each selected gig yields a chainable price path reaching into the recent year.

For each selected gig we keep one snapshot per month (latest in the month),
across the full window, so the matched-model index can chain quarter-to-quarter.

Input: data/cdx-index/gig-pages-classified.tsv (6 GB; streamed once)
(or a pre-filtered TSV via --prefiltered: category,timestamp,original,urlkey)
Output: data/pilot/recent-manifest.tsv (timestamp, original, category, gig_id, month)
"""

import argparse
import csv
import subprocess
import sys
from collections import defaultdict
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
CLASSIFIED = BASE_DIR / "data" / "cdx-index" / "gig-pages-classified.tsv"
OUTPUT = BASE_DIR / "data" / "pilot" / "recent-manifest.tsv"

WINDOW_START = "202407" # 2024Q3 — anchor quarters for chaining
TRAILING_QUARTERS = {"2025Q3", "2025Q4", "2026Q1", "2026Q2"}
# Categories too thin in the recent window to support an index (see 06f census).
SKIP_CATEGORIES = {"uncategorized", "data_entry", "data_analysis"}


def quarter(ts):
y, m = int(ts[:4]), int(ts[4:6])
return f"{y}Q{(m - 1) // 3 + 1}"


def gig_id(urlkey):
# urlkey looks like "com,fiverr)/seller/slug?sorted&query".
# Key by seller/slug only (drop query string) so the same gig merges
# across snapshots regardless of tracking params — matches the price
# pipeline, which keys by (seller, slug) from the URL path.
tail = urlkey.split(")/", 1)[1] if ")/" in urlkey else urlkey
return tail.split("?", 1)[0]


def iter_rows(prefiltered):
"""Yield (category, timestamp, original, urlkey) over the recent window."""
if prefiltered:
with open(prefiltered) as f:
for line in f:
p = line.rstrip("\n").split("\t")
if len(p) >= 4:
yield p[0], p[1], p[2], p[3]
return
# Stream the classified file directly (status 200, recent window only).
with open(CLASSIFIED) as f:
next(f, None) # header
for line in f:
p = line.rstrip("\n").split("\t")
if len(p) < 7:
continue
ts, status, cat = p[1], p[3], p[6]
if status != "200" or ts[:6] < WINDOW_START:
continue
yield cat, ts, p[2], p[0]


def main():
ap = argparse.ArgumentParser()
ap.add_argument("--prefiltered", type=Path, default=None,
help="Optional pre-filtered TSV: category,timestamp,original,urlkey")
args = ap.parse_args()

print("Building recent-window manifest...")
# gig -> {"cat":, "quarters": set, "months": {YYYYMM: (timestamp, original)}}
gigs = {}
n = 0
for cat, ts, original, urlkey in iter_rows(args.prefiltered):
n += 1
if cat in SKIP_CATEGORIES:
continue
gid = gig_id(urlkey)
g = gigs.get(gid)
if g is None:
g = gigs[gid] = {"cat": cat, "quarters": set(), "months": {}}
g["quarters"].add(quarter(ts))
ym = ts[:6]
# keep latest timestamp within the month
prev = g["months"].get(ym)
if prev is None or ts > prev[0]:
g["months"][ym] = (ts, original)
print(f" Scanned {n:,} snapshots; {len(gigs):,} distinct gigs in window")

# Select gigs with multi-quarter coverage reaching the trailing window.
selected = {gid: g for gid, g in gigs.items()
if len(g["quarters"]) >= 2 and (g["quarters"] & TRAILING_QUARTERS)}

per_cat = defaultdict(int)
for g in selected.values():
per_cat[g["cat"]] += 1
print(f" Selected {len(selected):,} gigs:")
for c in sorted(per_cat, key=lambda x: -per_cat[x]):
print(f" {c:<16}{per_cat[c]:>7,} gigs")

OUTPUT.parent.mkdir(parents=True, exist_ok=True)
rows = 0
with open(OUTPUT, "w", newline="") as f:
w = csv.writer(f, delimiter="\t")
w.writerow(["timestamp", "original", "category", "gig_id", "month"])
for gid, g in selected.items():
for ym, (ts, original) in sorted(g["months"].items()):
w.writerow([ts, original, g["cat"], gid, ym])
rows += 1
print(f" Wrote {rows:,} snapshot rows -> {OUTPUT}")
print(f" (~{rows} downloads; avg {rows/max(len(selected),1):.1f} snapshots/gig)")


if __name__ == "__main__":
main()
Loading
Loading