Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
42 changes: 42 additions & 0 deletions .claude/skills/linkedin-stats/fast/page/geo-monthly.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Build a MONTHLY ICP-geography trend from per-month Visitors XLS exports.

Why separate from parse-page-xls.py: LinkedIn's demographic sheets carry NO
date column — one export yields a single aggregate distribution for its whole
range. To get a monthly geography trend you must export ONE range per month.
This reads a directory of files named YYYY-MM.xls (each a Visitors export whose
Time range was that calendar month) and classifies each month's Location sheet
into ICP buckets via geo_classify.

Going forward the pipeline appends one month per run; for the initial backfill
the months were exported by hand.

Usage: python3 geo-monthly.py <xls-dir> <out.json>
"""
import sys, os, json, glob
import xlrd
from geo_classify import classify_rows


def build(xls_dir):
out = {}
for f in sorted(glob.glob(os.path.join(xls_dir, "20??-??.xls"))):
month = os.path.basename(f)[:7]
sh = xlrd.open_workbook(f).sheet_by_name("Location")
rows = [(sh.cell_value(r, 0), sh.cell_value(r, 1)) for r in range(1, sh.nrows)]
c = classify_rows(rows)
out[month] = {
"us": c["buckets"]["US"], "team": c["buckets"]["TEAM"],
"anti": c["buckets"]["ANTI"], "other": c["buckets"]["OTHER"],
"icp_pct": c["icp_pct"], "anti_pct": c["anti_pct"], "total": c["total"],
}
return out


if __name__ == "__main__":
if len(sys.argv) < 3:
print("usage: geo-monthly.py <xls-dir> <out.json>", file=sys.stderr); sys.exit(2)
data = build(sys.argv[1])
os.makedirs(os.path.dirname(sys.argv[2]) or ".", exist_ok=True)
json.dump({"months": data}, open(sys.argv[2], "w"), indent=2)
print("wrote", sys.argv[2], "months:", ", ".join(data), file=sys.stderr)
57 changes: 57 additions & 0 deletions .claude/skills/linkedin-stats/fast/page/geo_classify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Classify LinkedIn location strings into ICP geography buckets.

S&F ICP = US customers. Buckets:
US -> ICP (the target market)
Ukraine -> TEAM (S&F is UA-based; internal network, not a customer signal)
India / China -> ANTI (explicitly off-ICP)
everything else -> OTHER (off-target market)

LinkedIn convention: US metros carry NO country suffix ("Greater Boston",
"San Francisco Bay Area"); international locations end with ", <Country>".
So: a location with no trailing country is treated as US.

Pure functions — no I/O. Imported by parse-page-xls.py.
"""

COUNTRY_BUCKET = {
"ukraine": "TEAM",
"india": "ANTI",
"china": "ANTI",
}
BUCKETS = ("US", "TEAM", "ANTI", "OTHER")


def country_of(loc: str) -> str:
"""Best-effort country from a LinkedIn location string."""
parts = [p.strip() for p in str(loc).split(",") if p.strip()]
if len(parts) <= 1:
return "United States" # US convention: metro with no country suffix
return parts[-1] # trailing token is the country (doubled tails collapse to same value)


def bucket_of(loc: str) -> str:
c = country_of(loc).lower()
if c in ("united states", "usa", "us"):
return "US"
return COUNTRY_BUCKET.get(c, "OTHER")


def classify_rows(rows):
"""rows: iterable of (location, count). Returns {buckets, countries, total, icp_pct, anti_pct}."""
buckets = {b: 0 for b in BUCKETS}
countries = {}
for loc, n in rows:
if not isinstance(n, (int, float)):
continue
buckets[bucket_of(loc)] += n
c = country_of(loc)
countries[c] = countries.get(c, 0) + n
total = sum(buckets.values())
return {
"buckets": {b: round(buckets[b]) for b in BUCKETS},
"total": round(total),
"icp_pct": round(100 * buckets["US"] / total, 1) if total else 0.0,
"anti_pct": round(100 * buckets["ANTI"] / total, 1) if total else 0.0,
"countries": dict(sorted(countries.items(), key=lambda x: -x[1])),
}
13 changes: 13 additions & 0 deletions .claude/skills/linkedin-stats/fast/page/parse-page-xls.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"""
import sys, os, json, datetime, collections
import xlrd
from geo_classify import classify_rows


def _date(v):
Expand Down Expand Up @@ -58,6 +59,14 @@ def _demo(path, sheet_name, topn=8):
return [[n, int(v)] for n, v in rows[:topn] if isinstance(v, (int, float))]


def _geo(path):
"""Classify the full Location sheet into ICP geography buckets (US/TEAM/ANTI/OTHER)."""
wb = xlrd.open_workbook(path)
sh = wb.sheet_by_name("Location")
rows = [(sh.cell_value(r, 0), sh.cell_value(r, 1)) for r in range(1, sh.nrows)]
return classify_rows(rows)


def last_months(n):
today = datetime.date.today()
out = []
Expand Down Expand Up @@ -111,6 +120,10 @@ def parse(xls_dir, months):
"months": out_months,
"visitor_demographics": {s: _demo(os.path.join(xls_dir, "visitors.xls"), s) for s in demo_sheets},
"follower_demographics": {s: _demo(os.path.join(xls_dir, "followers.xls"), s) for s in demo_sheets},
"geography": {
"visitors": _geo(os.path.join(xls_dir, "visitors.xls")),
"followers": _geo(os.path.join(xls_dir, "followers.xls")),
},
}


Expand Down
110 changes: 99 additions & 11 deletions .github/scripts/build-page-dashboard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,23 @@ import fs from 'node:fs';
const args = Object.fromEntries(process.argv.slice(2).map((a, i, arr) =>
a.startsWith('--') ? [a.slice(2), arr[i + 1]] : [null, null]).filter(([k]) => k));
const IN = args.in || 'dashboards/li-stats/page/monthly.json';
const GEO_IN = args.geo || 'dashboards/li-stats/page/geo-monthly.json';
const OUT = args.out || 'dashboards/grafana/linkedin-page.json';

const src = JSON.parse(fs.readFileSync(IN, 'utf8'));
const months = Object.entries(src.months).map(([month, m]) => ({ month, ...m }));
// Monthly ICP-geography trend (one Visitors export per calendar month).
const geoMonthly = fs.existsSync(GEO_IN)
? Object.entries(JSON.parse(fs.readFileSync(GEO_IN, 'utf8')).months).map(([month, g]) => ({ month, ...g }))
: [];
// 6-month VISITOR geography = sum of the monthly exports (Location is additive
// "total views"), so the headline matches the rest of the 6-month dashboard.
const vis6 = (() => {
const b = { US: 0, TEAM: 0, ANTI: 0, OTHER: 0 };
for (const r of geoMonthly) { b.US += r.us || 0; b.TEAM += r.team || 0; b.ANTI += r.anti || 0; b.OTHER += r.other || 0; }
const total = b.US + b.TEAM + b.ANTI + b.OTHER;
return { buckets: b, icp_pct: total ? Math.round((1000 * b.US) / total) / 10 : 0 };
})();

const DS = { type: 'yesoreyeram-infinity-datasource', uid: 'grafanacloud-infinity' };

Expand All @@ -39,6 +52,18 @@ function inlineTarget(rows, columns, refId = 'A') {
filters: [],
};
}
// Backend-parser inline target that honours a filterExpression on the `month`
// column — the ONLY way an inline panel responds to the $month variable
// (parser:'simple' ignores filterExpression; parser:'backend' applies it).
function filteredTarget(rows, keys, filterExpression, refId = 'A') {
return {
refId, datasource: DS, type: 'json', source: 'inline', format: 'table',
parser: 'backend', root_selector: '', data: JSON.stringify(rows),
columns: [{ selector: 'month', text: 'month', type: 'string' },
...keys.map((k) => ({ selector: k, text: k, type: 'number' }))],
filters: [], filterExpression,
};
}
const numCols = (keys) => [{ selector: 'month', text: 'month', type: 'string' },
...keys.map((k) => ({ selector: k, text: k, type: 'number' }))];
const demoCols = [{ selector: 'name', text: 'name', type: 'string' },
Expand Down Expand Up @@ -70,22 +95,85 @@ function bargauge(title, gridPos, rows) {
targets: [inlineTarget(rows, demoCols)],
};
}
// ICP share as a coloured stat: green above target, red below.
function icpStat(title, gridPos, pct) {
return {
id: nid(), type: 'stat', title, datasource: DS, gridPos,
fieldConfig: { defaults: { unit: 'percent', decimals: 1, min: 0, max: 100, thresholds: {
mode: 'absolute', steps: [{ color: 'red', value: null }, { color: 'orange', value: 30 }, { color: 'green', value: 50 }] } }, overrides: [] },
options: { reduceOptions: { values: false, calcs: ['lastNotNull'], fields: '/^v$/' }, textMode: 'value', colorMode: 'value', graphMode: 'none' },
targets: [inlineTarget([{ v: pct }], [{ selector: 'v', text: 'v', type: 'number' }])],
};
}

// Stat for the $month-selected value (filtered inline via backend parser).
function monthStat(title, gridPos, field, unit, colored = false) {
const defaults = { unit, decimals: unit === 'percent' ? 1 : 0 };
if (colored) defaults.thresholds = { mode: 'absolute',
steps: [{ color: 'red', value: null }, { color: 'orange', value: 30 }, { color: 'green', value: 50 }] };
return {
id: nid(), type: 'stat', title, datasource: DS, gridPos,
fieldConfig: { defaults, overrides: [] },
options: { reduceOptions: { values: false, calcs: ['lastNotNull'], fields: `/^${field}$/` },
textMode: 'value', colorMode: colored ? 'value' : 'none', graphMode: 'none' },
targets: [filteredTarget(geoMonthly, [field], 'month == "${month}"')],
};
}

const demoRows = (obj, cat) => (obj[cat] || []).map(([name, value]) => ({ name, value }));
const geo = src.geography || { visitors: { buckets: {}, icp_pct: 0 }, followers: { buckets: {}, icp_pct: 0 } };
const BUCKET_LABEL = { US: 'US · ICP', TEAM: 'Ukraine · team', ANTI: 'India / China · off-ICP', OTHER: 'Other · off-target' };
const geoRows = (b) => ['US', 'TEAM', 'ANTI', 'OTHER'].map((k) => ({ name: BUCKET_LABEL[k], value: (b || {})[k] || 0 }));

// Generic barchart over an explicit data array (used for the monthly geo trend).
function barchartData(title, gridPos, data, keys, unit = 'short', stacking = 'none') {
return {
id: nid(), type: 'barchart', title, datasource: DS, gridPos,
fieldConfig: { defaults: { unit, custom: { lineWidth: 1, fillOpacity: 80 } }, overrides: [] },
options: { orientation: 'auto', xTickLabelRotation: 0, showValue: 'auto', stacking, legend: { showLegend: true, placement: 'bottom' } },
targets: [inlineTarget(data, [{ selector: 'month', text: 'month', type: 'string' }, ...keys.map((k) => ({ selector: k, text: k, type: 'number' }))])],
};
}

const panels = [
{ id: nid(), type: 'row', title: 'Company Page — monthly', gridPos: { h: 1, w: 24, x: 0, y: 0 }, collapsed: false, panels: [] },
barchart('Page views & unique visitors', { h: 8, w: 12, x: 0, y: 1 }, ['page_views', 'unique_visitors']),
barchart('New followers', { h: 8, w: 6, x: 12, y: 1 }, ['new_followers']),
barchart('Post impressions', { h: 8, w: 6, x: 18, y: 1 }, ['post_impressions']),
table('Monthly metrics', { h: 8, w: 24, x: 0, y: 9 }),
{ id: nid(), type: 'row', title: 'Audience (12-month snapshot)', gridPos: { h: 1, w: 24, x: 0, y: 17 }, collapsed: false, panels: [] },
bargauge('Visitors by seniority', { h: 8, w: 12, x: 0, y: 18 }, demoRows(src.visitor_demographics, 'Seniority')),
bargauge('Visitors by industry', { h: 8, w: 12, x: 12, y: 18 }, demoRows(src.visitor_demographics, 'Industry')),
bargauge('Followers by seniority', { h: 8, w: 12, x: 0, y: 26 }, demoRows(src.follower_demographics, 'Seniority')),
bargauge('Followers by industry', { h: 8, w: 12, x: 12, y: 26 }, demoRows(src.follower_demographics, 'Industry')),
{ id: nid(), type: 'row', title: 'ICP geography (visitors · last 6 months)', gridPos: { h: 1, w: 24, x: 0, y: 0 }, collapsed: false, panels: [] },
icpStat('US · ICP share — visitors (6 mo)', { h: 6, w: 6, x: 0, y: 1 }, vis6.icp_pct),
icpStat('US · ICP share — followers (base)', { h: 6, w: 6, x: 6, y: 1 }, geo.followers.icp_pct),
bargauge('Visitors by ICP bucket (6 mo)', { h: 6, w: 6, x: 12, y: 1 }, geoRows(vis6.buckets)),
bargauge('Followers by ICP bucket (base)', { h: 6, w: 6, x: 18, y: 1 }, geoRows(geo.followers.buckets)),
barchartData('US · ICP share of visitors by month', { h: 8, w: 12, x: 0, y: 7 }, geoMonthly, ['icp_pct'], 'percent'),
barchartData('Visitor geography by month', { h: 8, w: 12, x: 12, y: 7 }, geoMonthly, ['us', 'team', 'anti', 'other'], 'short', 'normal'),

{ id: nid(), type: 'row', title: 'Selected month — pick $month above', gridPos: { h: 1, w: 24, x: 0, y: 15 }, collapsed: false, panels: [] },
monthStat('US · ICP share ($month)', { h: 5, w: 6, x: 0, y: 16 }, 'icp_pct', 'percent', true),
monthStat('India / China share ($month)', { h: 5, w: 6, x: 6, y: 16 }, 'anti_pct', 'percent'),
monthStat('US visitors ($month)', { h: 5, w: 6, x: 12, y: 16 }, 'us', 'short'),
monthStat('India / China visitors ($month)', { h: 5, w: 6, x: 18, y: 16 }, 'anti', 'short'),

{ id: nid(), type: 'row', title: 'Company Page — monthly', gridPos: { h: 1, w: 24, x: 0, y: 21 }, collapsed: false, panels: [] },
barchart('Page views & unique visitors', { h: 8, w: 12, x: 0, y: 22 }, ['page_views', 'unique_visitors']),
barchart('New followers', { h: 8, w: 6, x: 12, y: 22 }, ['new_followers']),
barchart('Post impressions', { h: 8, w: 6, x: 18, y: 22 }, ['post_impressions']),
table('Monthly metrics', { h: 8, w: 24, x: 0, y: 30 }),

{ id: nid(), type: 'row', title: 'Audience (12-month snapshot)', gridPos: { h: 1, w: 24, x: 0, y: 38 }, collapsed: false, panels: [] },
bargauge('Visitors by seniority', { h: 8, w: 12, x: 0, y: 39 }, demoRows(src.visitor_demographics, 'Seniority')),
bargauge('Visitors by industry', { h: 8, w: 12, x: 12, y: 39 }, demoRows(src.visitor_demographics, 'Industry')),
bargauge('Followers by seniority', { h: 8, w: 12, x: 0, y: 47 }, demoRows(src.follower_demographics, 'Seniority')),
bargauge('Followers by industry', { h: 8, w: 12, x: 12, y: 47 }, demoRows(src.follower_demographics, 'Industry')),
];

// $month picker — Custom variable in "display : value" format (Query type
// can't read Infinity in v0alpha1 dashboards, so the list is embedded).
const monthValues = geoMonthly.map((r) => r.month);
const monthVar = {
name: 'month', type: 'custom', label: 'Month',
query: monthValues.map((m) => `${m} : ${m}`).join(', '),
current: monthValues.length ? { text: monthValues[monthValues.length - 1], value: monthValues[monthValues.length - 1] } : {},
options: monthValues.map((m, i) => ({ text: m, value: m, selected: i === monthValues.length - 1 })),
includeAll: false, multi: false,
};

const dashboard = {
uid: 'linkedin-page',
title: 'LinkedIn Stats — Company Page',
Expand All @@ -95,7 +183,7 @@ const dashboard = {
version: 1,
refresh: '',
time: { from: 'now-1y', to: 'now' },
templating: { list: [] },
templating: { list: monthValues.length ? [monthVar] : [] },
annotations: { list: [] },
panels,
};
Expand Down
Loading