weftens 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +178 -12
- package/agent-cores/amazon-ppc/package.json +17 -0
- package/agent-cores/amazon-ppc/src/cli.mjs +66 -0
- package/agent-cores/amazon-ppc/src/optimize.mjs +272 -0
- package/agent-cores/amazon-ppc/src/parse.mjs +153 -0
- package/agent-cores/amazon-ppc/test/fixtures/search-term-report.csv +8 -0
- package/agent-cores/amazon-ppc/test/fixtures/search-term-report.json +5 -0
- package/agent-cores/amazon-ppc/test/optimize.test.mjs +161 -0
- package/agent-cores/content-ops/package.json +17 -0
- package/agent-cores/content-ops/src/cli.mjs +51 -0
- package/agent-cores/content-ops/src/inventory.mjs +343 -0
- package/agent-cores/content-ops/src/parse.mjs +121 -0
- package/agent-cores/content-ops/test/fixtures/cms-export.csv +13 -0
- package/agent-cores/content-ops/test/fixtures/cms-export.json +31 -0
- package/agent-cores/content-ops/test/inventory.test.mjs +177 -0
- package/agent-cores/meta-ads/package.json +16 -0
- package/agent-cores/meta-ads/src/analyze.mjs +166 -0
- package/agent-cores/meta-ads/src/cli.mjs +39 -0
- package/agent-cores/meta-ads/src/parse.mjs +123 -0
- package/agent-cores/meta-ads/test/analyze.test.js +72 -0
- package/agent-cores/meta-ads/test/fixtures/meta-ads.csv +6 -0
- package/agent-cores/paid-search/package.json +17 -0
- package/agent-cores/paid-search/src/analyze.mjs +303 -0
- package/agent-cores/paid-search/src/cli.mjs +57 -0
- package/agent-cores/paid-search/src/parse.mjs +158 -0
- package/agent-cores/paid-search/test/analyze.test.mjs +120 -0
- package/agent-cores/paid-search/test/fixtures/campaigns.csv +7 -0
- package/agent-cores/paid-search/test/fixtures/sample.json +9 -0
- package/agent-cores/paid-search/test/fixtures/search-terms.csv +11 -0
- package/agent-cores/paid-search/test/parse.test.mjs +56 -0
- package/agent-cores/seo-content/package.json +17 -0
- package/agent-cores/seo-content/src/cli.mjs +41 -0
- package/agent-cores/seo-content/src/parse.mjs +141 -0
- package/agent-cores/seo-content/src/strategy.mjs +266 -0
- package/agent-cores/seo-content/test/fixtures/gsc-sample.csv +11 -0
- package/agent-cores/seo-content/test/parse.test.mjs +59 -0
- package/agent-cores/seo-content/test/strategy.test.mjs +108 -0
- package/agent-cores/seo-technical/package.json +17 -0
- package/agent-cores/seo-technical/src/audit.mjs +233 -0
- package/agent-cores/seo-technical/src/cli.mjs +48 -0
- package/agent-cores/seo-technical/src/fetch.mjs +51 -0
- package/agent-cores/seo-technical/test/audit.test.mjs +73 -0
- package/agent-cores/seo-technical/test/fixtures/sample.html +22 -0
- package/agent-cores/supply-verify/package.json +17 -0
- package/agent-cores/supply-verify/src/cli.mjs +44 -0
- package/agent-cores/supply-verify/src/verify.mjs +303 -0
- package/agent-cores/supply-verify/test/fixtures/placements.csv +8 -0
- package/agent-cores/supply-verify/test/fixtures/placements.json +4 -0
- package/agent-cores/supply-verify/test/verify.test.mjs +148 -0
- package/agents/agent2060.md +99 -0
- package/agents/amazon-ppc.md +42 -0
- package/agents/claims-content.md +35 -0
- package/agents/content-ops.md +46 -0
- package/agents/content-strategy.md +13 -0
- package/agents/disclosure-qc.md +13 -0
- package/agents/feed-optimizer.md +13 -0
- package/agents/growth-analyst.md +24 -0
- package/agents/hilbert.md +55 -0
- package/agents/ideator.md +13 -0
- package/agents/lippmann.md +68 -0
- package/agents/paid-search.md +35 -0
- package/agents/pio.md +34 -0
- package/agents/researcher.md +13 -0
- package/agents/richy.md +72 -0
- package/agents/script-editor.md +13 -0
- package/agents/seo-content.md +57 -0
- package/agents/seo-technical.md +53 -0
- package/agents/showrunner.md +13 -0
- package/agents/social-voice.md +38 -0
- package/agents/supply-verify.md +38 -0
- package/agents/video-editor.md +13 -0
- package/agents/writer.md +45 -0
- package/bin/weftens.js +234 -0
- package/content-org/.preflight/telemetry.jsonl +6 -0
- package/content-org/briefs/BRIEFS.md +47 -0
- package/content-org/config.mjs +78 -0
- package/content-org/cost.mjs +36 -0
- package/content-org/lead.js +64 -0
- package/content-org/metrics.mjs +166 -0
- package/content-org/orchestrator.mjs +246 -0
- package/content-org/package.json +14 -0
- package/content-org/producer.mjs +59 -0
- package/content-org/publish.mjs +34 -0
- package/content-org/review.mjs +32 -0
- package/content-org/studio/README.md +27 -0
- package/content-org/studio/assemble.sh +48 -0
- package/content-org/test.mjs +110 -0
- package/index.js +32 -0
- package/package.json +49 -10
- package/providers.json.example +9 -0
- package/src/agent-router.js +124 -0
- package/src/agent.js +123 -0
- package/src/format.js +66 -0
- package/src/invoke.js +129 -0
- package/src/pio-client.js +142 -0
- package/src/provider.js +81 -0
- package/src/registry.js +160 -0
- package/src/richy-client.js +216 -0
- package/src/router.js +34 -0
- package/src/synthesize.js +111 -0
- package/src/weftens.js +61 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Strategy-engine tests — run with `node --test`. Every assertion pins a SPECIFIC
|
|
2
|
+
// seeded issue in the fixture. If these pass, the engine is doing real arithmetic
|
|
3
|
+
// and threshold logic, not echoing a prompt.
|
|
4
|
+
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { parseRecords } from '../src/parse.mjs';
|
|
11
|
+
import { buildStrategy, expectedCtr, classifyIntent } from '../src/strategy.mjs';
|
|
12
|
+
|
|
13
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const csv = readFileSync(join(__dirname, 'fixtures', 'gsc-sample.csv'), 'utf8');
|
|
15
|
+
const records = parseRecords(csv);
|
|
16
|
+
const result = buildStrategy(records);
|
|
17
|
+
|
|
18
|
+
const q = (list, query) => list.find((x) => x.query === query);
|
|
19
|
+
|
|
20
|
+
test('striking-distance detects the two page-two keywords (pos 11-20)', () => {
|
|
21
|
+
const queries = result.strikingDistance.map((r) => r.query).sort();
|
|
22
|
+
assert.deepEqual(queries, ['best crm for solar installers', 'crm for real estate agents']);
|
|
23
|
+
for (const r of result.strikingDistance) {
|
|
24
|
+
assert.ok(r.position >= 11 && r.position <= 20);
|
|
25
|
+
assert.equal(r.lever, 'improve-rank');
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('striking-distance click math is real: 2000 impr x CTR@pos5 - 20 now', () => {
|
|
30
|
+
const rec = q(result.strikingDistance, 'crm for real estate agents');
|
|
31
|
+
// expectedCtr(5) = 0.061 -> 2000*0.061 = 122 ; gain = 122 - 20 = 102
|
|
32
|
+
assert.equal(rec.projectedCtr, 0.061);
|
|
33
|
+
assert.equal(rec.projectedClicks, 122);
|
|
34
|
+
assert.equal(rec.opportunityClicks, 102);
|
|
35
|
+
assert.match(rec.math, /2000 impr/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('high-impression/low-CTR flags "free crm" with the title/meta lever', () => {
|
|
39
|
+
const rec = q(result.lowCtrOpportunities, 'free crm');
|
|
40
|
+
assert.ok(rec, 'free crm surfaced as a low-CTR opportunity');
|
|
41
|
+
assert.equal(rec.lever, 'improve-ctr');
|
|
42
|
+
// pos4 expected CTR 0.08 -> 5000*0.08 = 400 ; gain = 400 - 50 = 350
|
|
43
|
+
assert.equal(rec.projectedClicks, 400);
|
|
44
|
+
assert.equal(rec.opportunityClicks, 350);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a healthy page-one keyword is NOT flagged as low-CTR', () => {
|
|
48
|
+
// "best crm software" pos3, 11% CTR is above 70% of expected(3)=0.11
|
|
49
|
+
assert.equal(q(result.lowCtrOpportunities, 'best crm software'), undefined);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('cannibalization: "crm pricing" splits across two URLs', () => {
|
|
53
|
+
const rec = q(result.cannibalization, 'crm pricing');
|
|
54
|
+
assert.ok(rec, 'cannibalization detected');
|
|
55
|
+
assert.equal(rec.urlCount, 2);
|
|
56
|
+
assert.equal(rec.primaryUrl, 'https://acme.example/pricing');
|
|
57
|
+
assert.deepEqual(rec.competingUrls, ['https://acme.example/plans']);
|
|
58
|
+
// 300 of 1100 impressions land off the primary URL -> ~0.273, above the 0.2 warn line
|
|
59
|
+
assert.equal(rec.splitShare, 0.273);
|
|
60
|
+
assert.equal(rec.severity, 'warn');
|
|
61
|
+
assert.equal(rec.confidence, 'verified-in-data');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('a single-URL query is not reported as cannibalization', () => {
|
|
65
|
+
assert.equal(q(result.cannibalization, 'free crm'), undefined);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('opportunity list is ranked by modeled click gain, descending', () => {
|
|
69
|
+
const gains = result.opportunities.map((o) => o.opportunityClicks);
|
|
70
|
+
for (let i = 1; i < gains.length; i += 1) {
|
|
71
|
+
assert.ok(gains[i - 1] >= gains[i], 'sorted descending by opportunityClicks');
|
|
72
|
+
}
|
|
73
|
+
assert.ok(result.totalOpportunityClicks > 0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('intent classification is lexical and deterministic', () => {
|
|
77
|
+
assert.equal(classifyIntent('buy crm software online'), 'act');
|
|
78
|
+
assert.equal(classifyIntent('best crm software'), 'compare');
|
|
79
|
+
assert.equal(classifyIntent('how to use a crm'), 'learn');
|
|
80
|
+
assert.equal(classifyIntent('enterprise crm platform'), 'unknown');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('expectedCtr curve is monotonic down and interpolates fractional positions', () => {
|
|
84
|
+
assert.ok(expectedCtr(1) > expectedCtr(5));
|
|
85
|
+
assert.ok(expectedCtr(5) > expectedCtr(10));
|
|
86
|
+
assert.ok(expectedCtr(10) > expectedCtr(20));
|
|
87
|
+
const mid = expectedCtr(1.5);
|
|
88
|
+
assert.ok(mid < expectedCtr(1) && mid > expectedCtr(2), 'pos 1.5 lies between pos 1 and 2');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('topic clustering groups the crm queries under a shared token', () => {
|
|
92
|
+
const crm = result.clusters.find((c) => c.topic === 'crm');
|
|
93
|
+
assert.ok(crm, 'a "crm" cluster exists');
|
|
94
|
+
assert.ok(crm.size >= 5);
|
|
95
|
+
assert.ok(crm.impressions > 0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('same input -> byte-identical output (deterministic)', () => {
|
|
99
|
+
const a = JSON.stringify(buildStrategy(parseRecords(csv)));
|
|
100
|
+
const b = JSON.stringify(buildStrategy(parseRecords(csv)));
|
|
101
|
+
assert.equal(a, b);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('the templated read reports real counts, no fabricated prose', () => {
|
|
105
|
+
assert.match(result.summary, /10 queries analyzed/);
|
|
106
|
+
assert.match(result.summary, /striking-distance/);
|
|
107
|
+
assert.match(result.summary, /cannibalization/);
|
|
108
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agent-cores/seo-technical",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Technical-SEO auditor. Deterministic: fetch a URL, compute crawl/index/render findings. No LLM in the core.",
|
|
7
|
+
"bin": {
|
|
8
|
+
"seo-technical": "src/cli.mjs"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --test",
|
|
12
|
+
"audit": "node src/cli.mjs"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Deterministic technical-SEO audit. No LLM. Given a fetched page (and optionally
|
|
2
|
+
// its robots.txt and sitemap.xml), compute checkable findings grouped by root cause.
|
|
3
|
+
//
|
|
4
|
+
// Input is data, not a prompt. Every finding carries evidence a developer can act on.
|
|
5
|
+
|
|
6
|
+
const SEVERITY_ORDER = { fail: 0, warn: 1, info: 2, pass: 3 };
|
|
7
|
+
|
|
8
|
+
// --- tiny, dependency-free HTML extractors (adequate for the specific tags we check) ---
|
|
9
|
+
|
|
10
|
+
function firstMatch(re, s) {
|
|
11
|
+
const m = re.exec(s);
|
|
12
|
+
return m ? m[1] : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function allMatches(re, s) {
|
|
16
|
+
const out = [];
|
|
17
|
+
let m;
|
|
18
|
+
while ((m = re.exec(s)) !== null) out.push(m);
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getAttr(tag, name) {
|
|
23
|
+
const m = new RegExp(`${name}\\s*=\\s*["']([^"']*)["']`, 'i').exec(tag);
|
|
24
|
+
return m ? m[1] : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function metaContent(html, nameOrProp, value) {
|
|
28
|
+
// find <meta ... name="robots" ... content="...">
|
|
29
|
+
const tags = allMatches(/<meta\b[^>]*>/gi, html).map((m) => m[0]);
|
|
30
|
+
for (const tag of tags) {
|
|
31
|
+
const key = getAttr(tag, nameOrProp);
|
|
32
|
+
if (key && key.toLowerCase() === value.toLowerCase()) return getAttr(tag, 'content');
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function auditPage(input) {
|
|
38
|
+
const {
|
|
39
|
+
url = null,
|
|
40
|
+
finalUrl = url,
|
|
41
|
+
status = null,
|
|
42
|
+
redirectChain = [],
|
|
43
|
+
html = '',
|
|
44
|
+
robotsTxt = null,
|
|
45
|
+
sitemapXml = null,
|
|
46
|
+
} = input;
|
|
47
|
+
|
|
48
|
+
const findings = [];
|
|
49
|
+
const add = (f) => findings.push(f);
|
|
50
|
+
|
|
51
|
+
// --- HTTP status & redirects ---
|
|
52
|
+
if (status === null) {
|
|
53
|
+
add({ id: 'http-status', category: 'crawl', severity: 'info', message: 'No HTTP status provided (offline audit).' });
|
|
54
|
+
} else if (status >= 200 && status < 300) {
|
|
55
|
+
add({ id: 'http-status', category: 'crawl', severity: 'pass', message: `HTTP ${status}.`, evidence: String(status) });
|
|
56
|
+
} else if (status >= 300 && status < 400) {
|
|
57
|
+
add({ id: 'http-status', category: 'crawl', severity: 'warn', message: `HTTP ${status} redirect at the requested URL.`, evidence: String(status) });
|
|
58
|
+
} else {
|
|
59
|
+
add({ id: 'http-status', category: 'crawl', severity: 'fail', message: `HTTP ${status} — page not served.`, evidence: String(status) });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (redirectChain.length > 1) {
|
|
63
|
+
add({
|
|
64
|
+
id: 'redirect-chain',
|
|
65
|
+
category: 'crawl',
|
|
66
|
+
severity: redirectChain.length > 2 ? 'warn' : 'info',
|
|
67
|
+
message: `${redirectChain.length}-hop redirect chain wastes crawl budget and link equity.`,
|
|
68
|
+
evidence: redirectChain.join(' -> '),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (finalUrl && finalUrl.startsWith('http://')) {
|
|
73
|
+
add({ id: 'https', category: 'crawl', severity: 'fail', message: 'Final URL is not HTTPS.', evidence: finalUrl });
|
|
74
|
+
} else if (finalUrl && finalUrl.startsWith('https://')) {
|
|
75
|
+
add({ id: 'https', category: 'crawl', severity: 'pass', message: 'Served over HTTPS.', evidence: finalUrl });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- Indexability: meta robots ---
|
|
79
|
+
const robotsMeta = metaContent(html, 'name', 'robots');
|
|
80
|
+
if (robotsMeta && /noindex/i.test(robotsMeta)) {
|
|
81
|
+
add({ id: 'meta-robots', category: 'index', severity: 'fail', message: 'Page is set to noindex — it will not rank.', evidence: robotsMeta });
|
|
82
|
+
} else if (robotsMeta) {
|
|
83
|
+
add({ id: 'meta-robots', category: 'index', severity: 'info', message: `meta robots: ${robotsMeta}.`, evidence: robotsMeta });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- Canonical ---
|
|
87
|
+
const canonicalTag = allMatches(/<link\b[^>]*>/gi, html).map((m) => m[0]).find((t) => /rel\s*=\s*["']canonical["']/i.test(t));
|
|
88
|
+
if (!canonicalTag) {
|
|
89
|
+
add({ id: 'canonical', category: 'index', severity: 'warn', message: 'No canonical link — duplicate-content risk across URL variants.' });
|
|
90
|
+
} else {
|
|
91
|
+
const href = getAttr(canonicalTag, 'href');
|
|
92
|
+
const selfRef = finalUrl && href && (href === finalUrl || href.replace(/\/$/, '') === finalUrl.replace(/\/$/, ''));
|
|
93
|
+
add({
|
|
94
|
+
id: 'canonical',
|
|
95
|
+
category: 'index',
|
|
96
|
+
severity: selfRef || !finalUrl ? 'pass' : 'info',
|
|
97
|
+
message: selfRef ? 'Self-referential canonical.' : `Canonical points elsewhere: ${href}.`,
|
|
98
|
+
evidence: href,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- Title ---
|
|
103
|
+
const title = firstMatch(/<title[^>]*>([\s\S]*?)<\/title>/i, html);
|
|
104
|
+
if (!title || !title.trim()) {
|
|
105
|
+
add({ id: 'title', category: 'content-signals', severity: 'fail', message: 'Missing <title>.' });
|
|
106
|
+
} else {
|
|
107
|
+
const len = title.trim().length;
|
|
108
|
+
const sev = len < 10 || len > 65 ? 'warn' : 'pass';
|
|
109
|
+
add({ id: 'title', category: 'content-signals', severity: sev, message: `Title ${len} chars${sev === 'warn' ? ' (aim 10–65)' : ''}.`, evidence: title.trim() });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- Meta description ---
|
|
113
|
+
const desc = metaContent(html, 'name', 'description');
|
|
114
|
+
if (!desc) {
|
|
115
|
+
add({ id: 'meta-description', category: 'content-signals', severity: 'warn', message: 'No meta description.' });
|
|
116
|
+
} else {
|
|
117
|
+
const len = desc.length;
|
|
118
|
+
const sev = len < 50 || len > 160 ? 'warn' : 'pass';
|
|
119
|
+
add({ id: 'meta-description', category: 'content-signals', severity: sev, message: `Meta description ${len} chars${sev === 'warn' ? ' (aim 50–160)' : ''}.`, evidence: desc });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- Viewport (mobile) ---
|
|
123
|
+
const viewport = metaContent(html, 'name', 'viewport');
|
|
124
|
+
add(viewport
|
|
125
|
+
? { id: 'viewport', category: 'render', severity: 'pass', message: 'Mobile viewport declared.', evidence: viewport }
|
|
126
|
+
: { id: 'viewport', category: 'render', severity: 'warn', message: 'No viewport meta — mobile rendering degraded.' });
|
|
127
|
+
|
|
128
|
+
// --- H1 ---
|
|
129
|
+
const h1s = allMatches(/<h1\b[^>]*>([\s\S]*?)<\/h1>/gi, html);
|
|
130
|
+
if (h1s.length === 0) {
|
|
131
|
+
add({ id: 'h1', category: 'content-signals', severity: 'warn', message: 'No <h1>.' });
|
|
132
|
+
} else if (h1s.length > 1) {
|
|
133
|
+
add({ id: 'h1', category: 'content-signals', severity: 'info', message: `${h1s.length} <h1> elements (usually one is intended).` });
|
|
134
|
+
} else {
|
|
135
|
+
add({ id: 'h1', category: 'content-signals', severity: 'pass', message: 'Exactly one <h1>.', evidence: h1s[0][1].replace(/<[^>]+>/g, '').trim().slice(0, 120) });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- JSON-LD structured data (grounds AI-answer / rich-result eligibility) ---
|
|
139
|
+
const ldScripts = allMatches(/<script\b[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi, html);
|
|
140
|
+
if (ldScripts.length === 0) {
|
|
141
|
+
add({ id: 'jsonld', category: 'structured-data', severity: 'warn', message: 'No JSON-LD structured data — weaker grounding for rich results and AI answers.' });
|
|
142
|
+
} else {
|
|
143
|
+
const types = [];
|
|
144
|
+
let broken = 0;
|
|
145
|
+
for (const m of ldScripts) {
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(m[1].trim());
|
|
148
|
+
const collect = (o) => {
|
|
149
|
+
if (Array.isArray(o)) return o.forEach(collect);
|
|
150
|
+
if (o && typeof o === 'object' && o['@type']) types.push(...[].concat(o['@type']));
|
|
151
|
+
};
|
|
152
|
+
collect(parsed);
|
|
153
|
+
} catch {
|
|
154
|
+
broken += 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (broken > 0) add({ id: 'jsonld-invalid', category: 'structured-data', severity: 'fail', message: `${broken} of ${ldScripts.length} JSON-LD blocks fail to parse — invalid to every consumer.` });
|
|
158
|
+
if (types.length) add({ id: 'jsonld', category: 'structured-data', severity: 'pass', message: `JSON-LD present: ${[...new Set(types)].join(', ')}.`, evidence: [...new Set(types)].join(', ') });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- hreflang ---
|
|
162
|
+
const hreflangs = allMatches(/<link\b[^>]*>/gi, html).map((m) => m[0]).filter((t) => /hreflang\s*=/i.test(t));
|
|
163
|
+
if (hreflangs.length) add({ id: 'hreflang', category: 'index', severity: 'info', message: `${hreflangs.length} hreflang alternates declared.` });
|
|
164
|
+
|
|
165
|
+
// --- Image alt coverage ---
|
|
166
|
+
const imgs = allMatches(/<img\b[^>]*>/gi, html).map((m) => m[0]);
|
|
167
|
+
if (imgs.length) {
|
|
168
|
+
const withAlt = imgs.filter((t) => getAttr(t, 'alt') !== null).length;
|
|
169
|
+
const pct = Math.round((withAlt / imgs.length) * 100);
|
|
170
|
+
add({ id: 'img-alt', category: 'content-signals', severity: pct < 80 ? 'warn' : 'pass', message: `${withAlt}/${imgs.length} images have alt (${pct}%).`, evidence: `${pct}%` });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- robots.txt ---
|
|
174
|
+
if (robotsTxt !== null) {
|
|
175
|
+
const blocksAll = /User-agent:\s*\*[\s\S]*?Disallow:\s*\/\s*($|\n)/i.test(robotsTxt);
|
|
176
|
+
add(blocksAll
|
|
177
|
+
? { id: 'robots-txt', category: 'crawl', severity: 'fail', message: 'robots.txt disallows all crawling (Disallow: /).' }
|
|
178
|
+
: { id: 'robots-txt', category: 'crawl', severity: 'pass', message: 'robots.txt does not block the whole site.' });
|
|
179
|
+
const sitemapDirective = firstMatch(/Sitemap:\s*(\S+)/i, robotsTxt);
|
|
180
|
+
add(sitemapDirective
|
|
181
|
+
? { id: 'robots-sitemap', category: 'crawl', severity: 'pass', message: 'robots.txt advertises a sitemap.', evidence: sitemapDirective }
|
|
182
|
+
: { id: 'robots-sitemap', category: 'crawl', severity: 'info', message: 'robots.txt has no Sitemap: directive.' });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- sitemap.xml ---
|
|
186
|
+
if (sitemapXml !== null) {
|
|
187
|
+
const urlCount = allMatches(/<loc>\s*([\s\S]*?)\s*<\/loc>/gi, sitemapXml).length;
|
|
188
|
+
add(urlCount > 0
|
|
189
|
+
? { id: 'sitemap', category: 'crawl', severity: 'pass', message: `sitemap.xml lists ${urlCount} URLs.`, evidence: String(urlCount) }
|
|
190
|
+
: { id: 'sitemap', category: 'crawl', severity: 'warn', message: 'sitemap.xml reachable but lists no <loc> URLs.' });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return summarize({ url, finalUrl, status, findings });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function summarize({ url, finalUrl, status, findings }) {
|
|
197
|
+
const counts = { fail: 0, warn: 0, info: 0, pass: 0 };
|
|
198
|
+
for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
|
|
199
|
+
|
|
200
|
+
const byCategory = {};
|
|
201
|
+
for (const f of findings) (byCategory[f.category] ||= []).push(f);
|
|
202
|
+
|
|
203
|
+
const ranked = [...findings].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
204
|
+
const actionable = ranked.filter((f) => f.severity === 'fail' || f.severity === 'warn');
|
|
205
|
+
|
|
206
|
+
// Deterministic 0–100 health score: full credit minus weighted penalties.
|
|
207
|
+
const graded = findings.filter((f) => f.severity !== 'info');
|
|
208
|
+
const penalty = counts.fail * 3 + counts.warn * 1;
|
|
209
|
+
const max = graded.length * 3 || 1;
|
|
210
|
+
const score = Math.max(0, Math.round(100 * (1 - penalty / max)));
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
url,
|
|
214
|
+
finalUrl,
|
|
215
|
+
status,
|
|
216
|
+
score,
|
|
217
|
+
counts,
|
|
218
|
+
diagnosis: buildDiagnosis(actionable, counts),
|
|
219
|
+
priorityFixes: actionable.slice(0, 10),
|
|
220
|
+
byCategory,
|
|
221
|
+
findings: ranked,
|
|
222
|
+
ignoreList: findings.filter((f) => f.severity === 'pass').map((f) => f.id),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function buildDiagnosis(actionable, counts) {
|
|
227
|
+
if (actionable.length === 0) return 'No crawl, index, or render blockers found in the checked set.';
|
|
228
|
+
const top = actionable[0];
|
|
229
|
+
const parts = [];
|
|
230
|
+
if (counts.fail) parts.push(`${counts.fail} blocking issue${counts.fail > 1 ? 's' : ''}`);
|
|
231
|
+
if (counts.warn) parts.push(`${counts.warn} warning${counts.warn > 1 ? 's' : ''}`);
|
|
232
|
+
return `${parts.join(' and ')}; start with: ${top.message}`;
|
|
233
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Real CLI. Two modes:
|
|
3
|
+
// seo-technical <url> live fetch + audit
|
|
4
|
+
// seo-technical --html <file> [--url] offline audit of a saved page
|
|
5
|
+
// Output: JSON findings on stdout (machine-readable), diagnosis on stderr.
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { auditPage } from './audit.mjs';
|
|
9
|
+
import { fetchPage } from './fetch.mjs';
|
|
10
|
+
|
|
11
|
+
function parseArgs(argv) {
|
|
12
|
+
const args = { _: [] };
|
|
13
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
14
|
+
const a = argv[i];
|
|
15
|
+
if (a === '--html') args.html = argv[++i];
|
|
16
|
+
else if (a === '--url') args.url = argv[++i];
|
|
17
|
+
else if (a === '--pretty') args.pretty = true;
|
|
18
|
+
else args._.push(a);
|
|
19
|
+
}
|
|
20
|
+
return args;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function main() {
|
|
24
|
+
const args = parseArgs(process.argv.slice(2));
|
|
25
|
+
let result;
|
|
26
|
+
|
|
27
|
+
if (args.html) {
|
|
28
|
+
const html = readFileSync(args.html, 'utf8');
|
|
29
|
+
result = auditPage({ url: args.url ?? null, finalUrl: args.url ?? null, html });
|
|
30
|
+
} else {
|
|
31
|
+
const url = args.url ?? args._[0];
|
|
32
|
+
if (!url) {
|
|
33
|
+
console.error('Usage: seo-technical <url> | seo-technical --html <file> [--url <url>]');
|
|
34
|
+
process.exitCode = 1;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const page = await fetchPage(url);
|
|
38
|
+
result = auditPage(page);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.error(`seo-technical: ${result.finalUrl ?? result.url ?? '(offline)'} — score ${result.score}/100 — ${result.diagnosis}`);
|
|
42
|
+
console.log(JSON.stringify(result, null, args.pretty ? 2 : 0));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
main().catch((err) => {
|
|
46
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
47
|
+
process.exitCode = 1;
|
|
48
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Live fetch layer. Retrieves a page plus its robots.txt and sitemap.xml so the
|
|
2
|
+
// deterministic audit has real bytes to check. Node 20+ global fetch; no deps.
|
|
3
|
+
|
|
4
|
+
function originOf(u) {
|
|
5
|
+
const url = new URL(u);
|
|
6
|
+
return `${url.protocol}//${url.host}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function tryText(u) {
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetch(u, { redirect: 'follow', headers: { 'user-agent': 'agent-cores-seo-technical/0.1 (+audit)' } });
|
|
12
|
+
if (!res.ok) return null;
|
|
13
|
+
return await res.text();
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Manually follow redirects so we can report the chain (fetch collapses it).
|
|
20
|
+
export async function fetchPage(startUrl, { maxHops = 5 } = {}) {
|
|
21
|
+
const chain = [];
|
|
22
|
+
let current = startUrl;
|
|
23
|
+
let res = null;
|
|
24
|
+
for (let i = 0; i < maxHops; i += 1) {
|
|
25
|
+
chain.push(current);
|
|
26
|
+
res = await fetch(current, { redirect: 'manual', headers: { 'user-agent': 'agent-cores-seo-technical/0.1 (+audit)' } });
|
|
27
|
+
if (res.status >= 300 && res.status < 400) {
|
|
28
|
+
const loc = res.headers.get('location');
|
|
29
|
+
if (!loc) break;
|
|
30
|
+
current = new URL(loc, current).toString();
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
const html = res ? await res.text() : '';
|
|
36
|
+
const origin = originOf(current);
|
|
37
|
+
const [robotsTxt, sitemapXml] = await Promise.all([
|
|
38
|
+
tryText(`${origin}/robots.txt`),
|
|
39
|
+
tryText(`${origin}/sitemap.xml`),
|
|
40
|
+
]);
|
|
41
|
+
return {
|
|
42
|
+
url: startUrl,
|
|
43
|
+
finalUrl: current,
|
|
44
|
+
status: res ? res.status : null,
|
|
45
|
+
redirectChain: chain,
|
|
46
|
+
headers: res ? Object.fromEntries(res.headers.entries()) : {},
|
|
47
|
+
html,
|
|
48
|
+
robotsTxt,
|
|
49
|
+
sitemapXml,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Offline tests — run with `node --test`, zero install. Asserts the auditor detects
|
|
2
|
+
// real, specific problems in a known fixture. If these pass, the core is doing real
|
|
3
|
+
// deterministic work, not echoing a prompt.
|
|
4
|
+
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { auditPage } from '../src/audit.mjs';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const html = readFileSync(join(__dirname, 'fixtures', 'sample.html'), 'utf8');
|
|
14
|
+
|
|
15
|
+
const byId = (r, id) => r.findings.find((f) => f.id === id);
|
|
16
|
+
|
|
17
|
+
test('flags noindex as a blocking failure', () => {
|
|
18
|
+
const r = auditPage({ url: 'https://example.com/', finalUrl: 'https://example.com/', status: 200, html });
|
|
19
|
+
const f = byId(r, 'meta-robots');
|
|
20
|
+
assert.ok(f, 'meta-robots finding exists');
|
|
21
|
+
assert.equal(f.severity, 'fail');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('detects invalid JSON-LD but still reports the valid block’s types', () => {
|
|
25
|
+
const r = auditPage({ html });
|
|
26
|
+
const invalid = byId(r, 'jsonld-invalid');
|
|
27
|
+
assert.ok(invalid && invalid.severity === 'fail', 'broken JSON-LD flagged as fail');
|
|
28
|
+
const ok = byId(r, 'jsonld');
|
|
29
|
+
assert.ok(ok && /Apartment/.test(ok.evidence), 'valid @type surfaced');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('warns on missing canonical and short meta description', () => {
|
|
33
|
+
const r = auditPage({ html });
|
|
34
|
+
assert.equal(byId(r, 'canonical').severity, 'warn');
|
|
35
|
+
assert.equal(byId(r, 'meta-description').severity, 'warn');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('passes a good title and single h1', () => {
|
|
39
|
+
const r = auditPage({ html });
|
|
40
|
+
assert.equal(byId(r, 'title').severity, 'pass');
|
|
41
|
+
assert.equal(byId(r, 'h1').severity, 'pass');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('computes image alt coverage (1 of 2 = 50%)', () => {
|
|
45
|
+
const r = auditPage({ html });
|
|
46
|
+
const f = byId(r, 'img-alt');
|
|
47
|
+
assert.equal(f.severity, 'warn');
|
|
48
|
+
assert.equal(f.evidence, '50%');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('robots.txt Disallow: / is a hard failure; sitemap URLs are counted', () => {
|
|
52
|
+
const r = auditPage({
|
|
53
|
+
html,
|
|
54
|
+
robotsTxt: 'User-agent: *\nDisallow: /\nSitemap: https://example.com/sitemap.xml',
|
|
55
|
+
sitemapXml: '<urlset><url><loc>https://example.com/</loc></url><url><loc>https://example.com/a</loc></url></urlset>',
|
|
56
|
+
});
|
|
57
|
+
assert.equal(byId(r, 'robots-txt').severity, 'fail');
|
|
58
|
+
assert.equal(byId(r, 'sitemap').evidence, '2');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('score is deterministic and penalizes failures', () => {
|
|
62
|
+
const r1 = auditPage({ status: 200, finalUrl: 'https://example.com/', html });
|
|
63
|
+
const r2 = auditPage({ status: 200, finalUrl: 'https://example.com/', html });
|
|
64
|
+
assert.equal(r1.score, r2.score, 'same input -> same score');
|
|
65
|
+
assert.ok(r1.score < 100, 'fixture has issues, so score is below 100');
|
|
66
|
+
assert.ok(r1.score >= 0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('diagnosis names the top actionable issue', () => {
|
|
70
|
+
const r = auditPage({ status: 200, html });
|
|
71
|
+
assert.match(r.diagnosis, /issue|warning/);
|
|
72
|
+
assert.ok(r.priorityFixes.length > 0);
|
|
73
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Visconti at Westshore — Apartments in Tampa</title>
|
|
7
|
+
<meta name="description" content="Too short.">
|
|
8
|
+
<meta name="robots" content="noindex, follow">
|
|
9
|
+
<script type="application/ld+json">
|
|
10
|
+
{ "@context": "https://schema.org", "@type": "Apartment", "name": "Visconti at Westshore" }
|
|
11
|
+
</script>
|
|
12
|
+
<script type="application/ld+json">
|
|
13
|
+
{ "@type": "LocalBusiness", "name": "broken", }
|
|
14
|
+
</script>
|
|
15
|
+
</head>
|
|
16
|
+
<body>
|
|
17
|
+
<h1>Visconti at Westshore</h1>
|
|
18
|
+
<img src="/a.jpg" alt="Pool">
|
|
19
|
+
<img src="/b.jpg">
|
|
20
|
+
<p>Content.</p>
|
|
21
|
+
</body>
|
|
22
|
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agent-cores/supply-verify",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Ad-spend verification engine. Deterministic: parse placement/log-level data, compute MFA/IVT/low-viewability waste and an evidenced exclusion list. No LLM in the core.",
|
|
7
|
+
"bin": {
|
|
8
|
+
"supply-verify": "src/cli.mjs"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --test",
|
|
12
|
+
"verify": "node src/cli.mjs"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Real CLI. Reads a placement / log-level report (CSV or JSON) from a file path
|
|
3
|
+
// and prints the deterministic waste report.
|
|
4
|
+
// supply-verify <file> audit a CSV or JSON placement report
|
|
5
|
+
// supply-verify <file> --pretty pretty-print the JSON
|
|
6
|
+
// supply-verify <file> --json|--csv force the input format
|
|
7
|
+
// Output: JSON result on stdout (machine-readable); one-line summary on stderr.
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { verifySupply } from './verify.mjs';
|
|
11
|
+
|
|
12
|
+
function parseArgs(argv) {
|
|
13
|
+
const args = { _: [] };
|
|
14
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
15
|
+
const a = argv[i];
|
|
16
|
+
if (a === '--pretty') args.pretty = true;
|
|
17
|
+
else if (a === '--json') args.format = 'json';
|
|
18
|
+
else if (a === '--csv') args.format = 'csv';
|
|
19
|
+
else if (a === '--needs-data') args.includeNeedsData = true;
|
|
20
|
+
else args._.push(a);
|
|
21
|
+
}
|
|
22
|
+
return args;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function main() {
|
|
26
|
+
const args = parseArgs(process.argv.slice(2));
|
|
27
|
+
const file = args._[0];
|
|
28
|
+
if (!file) {
|
|
29
|
+
console.error('Usage: supply-verify <placements.csv|.json> [--pretty] [--json|--csv] [--needs-data]');
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const text = readFileSync(file, 'utf8');
|
|
34
|
+
const result = verifySupply(text, { format: args.format, includeNeedsData: args.includeNeedsData });
|
|
35
|
+
console.error(`supply-verify: ${result.summary.placements} placements — $${result.summary.reclaimableSpend} reclaimable — ${result.summary.diagnosis}`);
|
|
36
|
+
console.log(JSON.stringify(result, null, args.pretty ? 2 : 0));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
main();
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
}
|