seo 0.0.15 → 0.2.0
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/LICENSE +201 -0
- package/PRIVACY.md +95 -0
- package/README.md +431 -35
- package/SECURITY.md +55 -0
- package/TERMS.md +94 -0
- package/TRADEMARKS.md +31 -0
- package/dist/cli.js +141 -0
- package/dist/cli.js.map +1 -0
- package/dist/dist-6bK_8r5M.js +713 -0
- package/dist/dist-6bK_8r5M.js.map +1 -0
- package/dist/dist-vRmqfhR4.js +3 -0
- package/dist/index.d.ts +18013 -0
- package/dist/index.js +27197 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp.d.ts +74 -0
- package/dist/mcp.js +28611 -0
- package/dist/mcp.js.map +1 -0
- package/dist/package-7aT8_Xp0.js +23818 -0
- package/dist/package-7aT8_Xp0.js.map +1 -0
- package/evals/README.md +117 -0
- package/evals/affected-urls.json +57 -0
- package/evals/ai-readiness.json +84 -0
- package/evals/ai-referrals.json +71 -0
- package/evals/ai-search-scorecard.json +70 -0
- package/evals/audit-page.json +57 -0
- package/evals/audit-urls.json +58 -0
- package/evals/cannibalisation.json +57 -0
- package/evals/community-intent.json +57 -0
- package/evals/compare-crawls.json +56 -0
- package/evals/content-optimization.json +57 -0
- package/evals/crawl-diff.json +58 -0
- package/evals/crawl-history.json +44 -0
- package/evals/crawl-report.json +44 -0
- package/evals/crawler-rules.json +44 -0
- package/evals/ctr-underperformers.json +57 -0
- package/evals/decaying-pages.json +57 -0
- package/evals/entity-readiness.json +57 -0
- package/evals/explain-crawl-issue.json +44 -0
- package/evals/generate-llms-txt.json +44 -0
- package/evals/geo-gaps.json +57 -0
- package/evals/index-coverage-plan.json +58 -0
- package/evals/index-coverage.json +70 -0
- package/evals/index-monitor.json +58 -0
- package/evals/index-watch.json +68 -0
- package/evals/internal-links.json +57 -0
- package/evals/link-recovery.json +58 -0
- package/evals/llms-txt-audit.json +44 -0
- package/evals/measure-change.json +58 -0
- package/evals/monthly-action-plan.json +56 -0
- package/evals/monthly-report.json +58 -0
- package/evals/narrative-report.json +58 -0
- package/evals/okf-build.json +44 -0
- package/evals/okf-validate.json +44 -0
- package/evals/page-opportunities.json +57 -0
- package/evals/performance-audit.json +57 -0
- package/evals/pseo-audit.json +84 -0
- package/evals/query-clusters.json +57 -0
- package/evals/quick-wins.json +86 -0
- package/evals/redirect-trace.json +57 -0
- package/evals/refresh-priorities.json +57 -0
- package/evals/search-performance-overview.json +86 -0
- package/evals/second-page.json +58 -0
- package/evals/segment-impact.json +57 -0
- package/evals/seo-fix-queue.json +85 -0
- package/evals/seo-geo-readiness.json +84 -0
- package/evals/seo-site-audit.json +85 -0
- package/evals/seo-to-ai-query.json +57 -0
- package/evals/seo.json +87 -0
- package/evals/setup-check.json +56 -0
- package/evals/site-crawl.json +44 -0
- package/evals/striking-distance.json +85 -0
- package/evals/technical-watch.json +57 -0
- package/evals/top-fixes.json +44 -0
- package/evals/traffic-anomaly.json +57 -0
- package/evals/update-correlation.json +57 -0
- package/evals/update-postmortem.json +57 -0
- package/package.json +103 -23
- package/skills/README.md +68 -0
- package/skills/seo/SKILL.md +99 -0
- package/skills/seo/agents/openai.yaml +4 -0
- package/.jshintrc +0 -18
- package/.npmignore +0 -3
- package/.travis.yml +0 -3
- package/Makefile +0 -10
- package/index.js +0 -2
- package/lib/middleware.js +0 -159
- package/lib/seo.js +0 -32
- package/test/init.js +0 -43
- package/test/middleware.test.js +0 -111
- package/test/seo-page-result.html +0 -9
- package/test/seo-page.html +0 -9
- package/test/seo-routes.js +0 -5
- package/test/vendor/jquery-1.11.1.min.js +0 -4
package/package.json
CHANGED
|
@@ -1,36 +1,116 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "seo",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The only SEO skill your agent needs. 50+ SEO audit tools through a local CLI and MCP server, using your own crawl, Search Console, and GA4 data.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"author": "Ian Nuttall",
|
|
8
|
+
"homepage": "https://seoskill.dev",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
|
-
"url": "git://github.com/
|
|
11
|
+
"url": "git+https://github.com/iannuttall/seo.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/iannuttall/seo/issues"
|
|
12
15
|
},
|
|
16
|
+
"funding": "https://github.com/iannuttall/seo",
|
|
13
17
|
"keywords": [
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
+
"seo",
|
|
19
|
+
"search-console",
|
|
20
|
+
"google-analytics",
|
|
21
|
+
"crawler",
|
|
22
|
+
"cli",
|
|
23
|
+
"mcp"
|
|
18
24
|
],
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
|
|
25
|
+
"main": "./dist/index.js",
|
|
26
|
+
"module": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./mcp": {
|
|
34
|
+
"types": "./dist/mcp.d.ts",
|
|
35
|
+
"import": "./dist/mcp.js"
|
|
36
|
+
},
|
|
37
|
+
"./package.json": "./package.json"
|
|
38
|
+
},
|
|
39
|
+
"bin": {
|
|
40
|
+
"seo": "./dist/cli.js"
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"dist",
|
|
44
|
+
"skills",
|
|
45
|
+
"evals",
|
|
46
|
+
"PRIVACY.md",
|
|
47
|
+
"SECURITY.md",
|
|
48
|
+
"TERMS.md",
|
|
49
|
+
"TRADEMARKS.md"
|
|
50
|
+
],
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public",
|
|
53
|
+
"provenance": true
|
|
54
|
+
},
|
|
55
|
+
"packageManager": "pnpm@11.11.0",
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=22.19.0"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"auth:inject-shared-client": "node scripts/inject-shared-oauth-client.mjs",
|
|
61
|
+
"build": "turbo run build && pnpm run build:package",
|
|
62
|
+
"build:package": "tsdown --config tsdown.config.ts",
|
|
63
|
+
"clean": "turbo run clean && rm -rf dist",
|
|
64
|
+
"dev": "turbo run dev",
|
|
65
|
+
"lint": "biome check . && node scripts/check-emdashes.mjs && node scripts/check-report-parity.mjs && pnpm run skills:validate && turbo run lint",
|
|
66
|
+
"lint:fix": "biome check . --write",
|
|
67
|
+
"loc:audit": "node scripts/loc-audit.mjs",
|
|
68
|
+
"pack:check": "pnpm pack --dry-run",
|
|
69
|
+
"prepack": "pnpm run skills:validate && pnpm run build:package && node --test scripts/package.test.mjs",
|
|
70
|
+
"security:audit": "pnpm audit --audit-level low",
|
|
71
|
+
"security:check": "pnpm run security:audit && pnpm run security:secrets",
|
|
72
|
+
"security:secrets": "node scripts/security-secrets.mjs",
|
|
73
|
+
"skills:validate": "node scripts/validate-skills.mjs",
|
|
74
|
+
"test": "turbo run test && pnpm run build:package && node --test scripts/package.test.mjs && pnpm run skills:validate",
|
|
75
|
+
"test:package-install": "pnpm run build:package && node --test scripts/installed-package.test.mjs",
|
|
76
|
+
"typecheck": "turbo run typecheck"
|
|
23
77
|
},
|
|
24
|
-
"homepage": "https://github.com/saschagehlich/node-seo",
|
|
25
78
|
"dependencies": {
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
79
|
+
"@clack/prompts": "^1.7.0",
|
|
80
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
81
|
+
"@mozilla/readability": "^0.6.0",
|
|
82
|
+
"@napi-rs/keyring": "^1.3.0",
|
|
83
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
84
|
+
"@types/node": "^22.20.1",
|
|
85
|
+
"better-sqlite3": "^12.11.1",
|
|
86
|
+
"citty": "^0.2.2",
|
|
87
|
+
"cli-table3": "^0.6.5",
|
|
88
|
+
"consola": "^3.4.2",
|
|
89
|
+
"debug": "^4.4.3",
|
|
90
|
+
"defuddle": "^0.19.1",
|
|
91
|
+
"jsonc-parser": "^3.3.1",
|
|
92
|
+
"lighthouse": "13.4.0",
|
|
93
|
+
"linkedom": "^0.18.13",
|
|
94
|
+
"open": "^11.0.0",
|
|
95
|
+
"p-queue": "^9.3.1",
|
|
96
|
+
"p-retry": "^8.0.0",
|
|
97
|
+
"picocolors": "^1.1.1",
|
|
98
|
+
"pino": "^10.3.1",
|
|
99
|
+
"pino-pretty": "^13.1.3",
|
|
100
|
+
"playwright-core": "1.61.1",
|
|
101
|
+
"proper-lockfile": "^4.1.2",
|
|
102
|
+
"robots-parser": "^3.0.1",
|
|
103
|
+
"schema-dts": "^2.0.0",
|
|
104
|
+
"undici": "^8.7.0",
|
|
105
|
+
"update-notifier": "^7.3.1",
|
|
106
|
+
"user-agents": "2.1.117",
|
|
107
|
+
"zod": "^4.4.3"
|
|
29
108
|
},
|
|
30
109
|
"devDependencies": {
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
110
|
+
"@biomejs/biome": "^2.5.3",
|
|
111
|
+
"@changesets/cli": "^2.31.0",
|
|
112
|
+
"tsdown": "^0.22.4",
|
|
113
|
+
"turbo": "^2.10.4",
|
|
114
|
+
"typescript": "^6.0.3"
|
|
35
115
|
}
|
|
36
116
|
}
|
package/skills/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Agent skills
|
|
2
|
+
|
|
3
|
+
This package ships one skill: `seo`. It teaches an agent how to use `seo`
|
|
4
|
+
without reading the whole repo.
|
|
5
|
+
|
|
6
|
+
The skill is a router, not a manual. It explains the discover, describe, run
|
|
7
|
+
flow, names the report ids for common jobs, and states the evidence rules that
|
|
8
|
+
apply to every report. It does not memorise each report. Per-report depth, when
|
|
9
|
+
to use a report, when to avoid it, its schema, the order to read its output,
|
|
10
|
+
what its evidence cannot support, and one verification step, is fetched at
|
|
11
|
+
runtime with `describe`. This keeps the skill small and always in sync with the
|
|
12
|
+
registry.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
The skill works best with the MCP server installed. This command is an
|
|
17
|
+
interactive setup flow for a human:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
seo mcp install
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The skill also works by calling the CLI directly when MCP tools are not
|
|
24
|
+
available. The npm package and repository contain the same skill. Install it
|
|
25
|
+
with the standard agent skills installer:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx skills add iannuttall/seo
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Use `seo skill list` or `seo skill path seo` to inspect the copy bundled with
|
|
32
|
+
the npm package.
|
|
33
|
+
|
|
34
|
+
## Discover, describe, run
|
|
35
|
+
|
|
36
|
+
The MCP path is deliberately small: discover ids with `seo_list_reports`, load
|
|
37
|
+
one report's full depth with `seo_describe_report`, then execute it with
|
|
38
|
+
`seo_run_report`. The same catalog is available without MCP:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
seo reports list --json
|
|
42
|
+
seo reports describe <report-id> --json
|
|
43
|
+
seo reports run <report-id> --params '<json>' --json
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Always describe a report before its first run. The describe response is the
|
|
47
|
+
per-report manual: its `readOrder`, `doNotClaim` limits, `verify` step, and
|
|
48
|
+
`related` ids come straight from the registry.
|
|
49
|
+
|
|
50
|
+
## Adding a report
|
|
51
|
+
|
|
52
|
+
Register the report once in `packages/mcp/src/report-registry.ts`, including its
|
|
53
|
+
depth guidance in `packages/mcp/src/report-guidance.ts` and
|
|
54
|
+
`packages/mcp/src/report-depth.ts` (`readOrder`, `doNotClaim`, `verify`,
|
|
55
|
+
`related`). CLI discovery, MCP discovery, and the skill then share its id,
|
|
56
|
+
description, input schema, handler, and depth automatically. No new skill file
|
|
57
|
+
is needed. The router only lists report ids for common jobs; a report earns a
|
|
58
|
+
mention there when it belongs in one of those chains.
|
|
59
|
+
|
|
60
|
+
Run `pnpm skills:validate` after changing the skill or the registry. The
|
|
61
|
+
validator checks the skill frontmatter and word count, that every command and
|
|
62
|
+
report id the skill names resolves, that the MCP tool names are real, and that
|
|
63
|
+
every `evals/*.json` is well formed.
|
|
64
|
+
|
|
65
|
+
## Evals
|
|
66
|
+
|
|
67
|
+
Behaviour tests for the skill live in the top-level `evals/` directory. See
|
|
68
|
+
[../evals/README.md](../evals/README.md) for the file shape and how to run them.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: seo
|
|
3
|
+
description: Use and read this skill immediately if the user request is in any way related to SEO or a site's organic search or AI search presence. That includes site audits, rankings, click or traffic changes, indexing problems, crawling, redirects, sitemaps, metadata, structured data, Core Web Vitals, internal links, content opportunities, Search Console or GA4 questions, Google update impact, llms.txt, AI search visibility in ChatGPT, Claude, Perplexity, or Google AI Overviews, and client SEO reporting. Routes to 52 evidence-backed local reports through the SEO CLI and MCP server.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# seo
|
|
7
|
+
|
|
8
|
+
`seo` is a local CLI, MCP server, and report engine. It answers SEO questions
|
|
9
|
+
with evidence from three sources: its own crawl of the site, Google Search
|
|
10
|
+
Console, and GA4. Every report returns structured JSON with the observed
|
|
11
|
+
evidence, derived findings, caveats, and provenance kept separate. Nothing is
|
|
12
|
+
hosted; data stays on the machine.
|
|
13
|
+
|
|
14
|
+
You do not need to memorise reports. Discover them at runtime.
|
|
15
|
+
|
|
16
|
+
## Discover, describe, run
|
|
17
|
+
|
|
18
|
+
With the MCP server (preferred):
|
|
19
|
+
|
|
20
|
+
1. `seo_list_reports` returns every report id with a one-line purpose,
|
|
21
|
+
optionally by category.
|
|
22
|
+
2. `seo_describe_report` with one id returns when to use it, when to avoid it,
|
|
23
|
+
its exact JSON Schema, the order to read its output in, what its evidence
|
|
24
|
+
cannot support, one verification step, and related report ids.
|
|
25
|
+
3. `seo_run_report` with the id and a bounded `params` object. Read
|
|
26
|
+
`structuredContent`, not the display text.
|
|
27
|
+
|
|
28
|
+
The same catalog exists without MCP:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
seo reports list --json
|
|
32
|
+
seo reports describe <report-id> --json
|
|
33
|
+
seo reports run <report-id> --params '<json>' --json
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Always describe a report before its first run in a session. The describe
|
|
37
|
+
response is the per-report manual: follow its `readOrder`, respect its
|
|
38
|
+
`doNotClaim` limits, and use its `related` ids to decide what to run next.
|
|
39
|
+
Reuse the schema for repeated runs. Do not guess parameters.
|
|
40
|
+
|
|
41
|
+
## Setup and selection
|
|
42
|
+
|
|
43
|
+
Run the `setup-check` report (or `seo doctor`) if auth state is unknown.
|
|
44
|
+
Saved project profiles are selected with `--project <id>`; list them with
|
|
45
|
+
`seo projects list --json`. Without a profile, pass `--site sc-domain:example.com`
|
|
46
|
+
for Search Console reports or `--url https://example.com` for crawl-only
|
|
47
|
+
reports. Crawl and page audits work with no Google connection at all. Always
|
|
48
|
+
pass `--json`; JSON mode never prompts.
|
|
49
|
+
|
|
50
|
+
## Common jobs
|
|
51
|
+
|
|
52
|
+
Each job lists report ids in a sensible starting order. Run the first, read
|
|
53
|
+
its evidence, then decide; do not run a whole chain blindly.
|
|
54
|
+
|
|
55
|
+
| Job | Reports |
|
|
56
|
+
|---|---|
|
|
57
|
+
| Page not indexed or missing from Google | `index-coverage`, `index-monitor` (URL Inspection), `audit-page`, `redirect-trace` |
|
|
58
|
+
| Traffic or clicks dropped | `search-performance-overview`, `traffic-anomaly`, `update-correlation`, `segment-impact`, `decaying-pages`, `link-recovery` |
|
|
59
|
+
| Audit a whole site | `report` command (main report), `site-crawl`, `top-fixes`, `ai-search-scorecard` |
|
|
60
|
+
| More clicks from existing pages | `quick-wins`, `ctr-underperformers`, `striking-distance`, `second-page`, `internal-links` |
|
|
61
|
+
| AI search visibility and readiness | `ai-search-scorecard`, `ai-readiness`, `geo-gaps`, `entity-readiness`, `llms-txt-audit`, `ai-referrals` |
|
|
62
|
+
| Plan content from real demand | `query-clusters`, `page-opportunities`, `content-optimization`, `cannibalisation` |
|
|
63
|
+
| Catch regressions over time | `technical-watch`, `crawl-diff`, `index-watch`, `measure-change` after a fix ships |
|
|
64
|
+
| Client-ready reporting | `monthly-report`, `narrative-report`, `monthly-action-plan` |
|
|
65
|
+
| Turn crawl findings into tickets | `top-fixes`, `affected-urls`, `explain-crawl-issue` |
|
|
66
|
+
|
|
67
|
+
A single page complaint usually needs both angles: the index evidence for that
|
|
68
|
+
URL and an `audit-page` pass for content or technical problems on the page
|
|
69
|
+
itself.
|
|
70
|
+
|
|
71
|
+
The main human report is the `seo report` command. It is the right first move
|
|
72
|
+
when the user asks a broad "how is my site doing" question; the focused
|
|
73
|
+
reports above are for specific symptoms.
|
|
74
|
+
|
|
75
|
+
## Evidence rules
|
|
76
|
+
|
|
77
|
+
- Check `dataStatus`, selection counts, `caveats`, and `warnings` before
|
|
78
|
+
summarising any report. Name skipped or incomplete evidence first.
|
|
79
|
+
- Partial, capped, filtered, or sampled sources never support a zero or an
|
|
80
|
+
all-clear. Grouped Search Console totals undercount because anonymised query
|
|
81
|
+
rows are withheld.
|
|
82
|
+
- Values marked heuristic are prioritisation aids, not forecasts. Never
|
|
83
|
+
promise clicks, rankings, indexing, or AI citations from any report.
|
|
84
|
+
- Quote `principle` and `evidenceRef` when explaining a recommendation, and
|
|
85
|
+
give the user the report's verification step alongside any suggested change.
|
|
86
|
+
- If a report returns no rows, say so plainly.
|
|
87
|
+
- Intentional controls such as `noindex`, canonicals, and robots rules are
|
|
88
|
+
observations until the user confirms they are unintended.
|
|
89
|
+
|
|
90
|
+
## Beyond the report catalog
|
|
91
|
+
|
|
92
|
+
The CLI also has direct commands for raw provider access and administration:
|
|
93
|
+
`seo gsc-query`, `seo url-inspect`, `seo ga4-report`, `seo updates`,
|
|
94
|
+
`seo crawl`, `seo export`, and `seo projects`. Use `seo help all` to list
|
|
95
|
+
everything. Prefer a registered report when one covers the question, because
|
|
96
|
+
reports carry provenance and caveats that raw queries do not.
|
|
97
|
+
|
|
98
|
+
Report output is cached locally. Pass `refresh: true` (or `--refresh`) only
|
|
99
|
+
when the user explicitly wants a fresh fetch.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
interface:
|
|
2
|
+
display_name: "seo"
|
|
3
|
+
short_description: "Use immediately for anything touching SEO, organic search, or AI search: audits, rankings, traffic changes, indexing, crawling, Search Console, GA4, or AI visibility"
|
|
4
|
+
default_prompt: "Use $seo to discover, describe, and run the right local SEO report and explain its evidence."
|
package/.jshintrc
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"globals" : {
|
|
3
|
-
/* MOCHA */
|
|
4
|
-
"describe" : false,
|
|
5
|
-
"it" : false,
|
|
6
|
-
"before" : false,
|
|
7
|
-
"beforeEach" : false,
|
|
8
|
-
"after" : false,
|
|
9
|
-
"afterEach" : false,
|
|
10
|
-
|
|
11
|
-
/* COMMONJS / NODE */
|
|
12
|
-
"module" : false,
|
|
13
|
-
"require" : false,
|
|
14
|
-
"console" : false,
|
|
15
|
-
"process" : false,
|
|
16
|
-
"global" : false
|
|
17
|
-
}
|
|
18
|
-
}
|
package/.npmignore
DELETED
package/.travis.yml
DELETED
package/Makefile
DELETED
package/index.js
DELETED
package/lib/middleware.js
DELETED
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
/* global document */
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
var fs = require('fs');
|
|
5
|
-
var path = require('path');
|
|
6
|
-
var crypto = require('crypto');
|
|
7
|
-
var phantom = require('phantom');
|
|
8
|
-
var Q = require('q');
|
|
9
|
-
|
|
10
|
-
function Middleware(options) {
|
|
11
|
-
this.options = options;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Handles the given request
|
|
16
|
-
* @param {Request} req
|
|
17
|
-
* @param {Response} res
|
|
18
|
-
* @param {Function} next [description]
|
|
19
|
-
* @public
|
|
20
|
-
*/
|
|
21
|
-
Middleware.prototype.request = function (req, res, next) {
|
|
22
|
-
var self = this;
|
|
23
|
-
var shouldTakeSnapshot = this.options.routes(req);
|
|
24
|
-
if (!shouldTakeSnapshot) return next();
|
|
25
|
-
|
|
26
|
-
var botRegex = /(google|yahoo|bing|baidu|jeeves|facebook|twitter|linkedin|xovibot)/i;
|
|
27
|
-
var isBot = botRegex.test(req.headers['user-agent']);
|
|
28
|
-
if (!isBot) return next();
|
|
29
|
-
|
|
30
|
-
this._checkForCache(req)
|
|
31
|
-
.then(function (inCache) {
|
|
32
|
-
if (!inCache) {
|
|
33
|
-
return self._createSnapshot(req);
|
|
34
|
-
}
|
|
35
|
-
})
|
|
36
|
-
.then(function () {
|
|
37
|
-
return self._serveSnapshot(req, res);
|
|
38
|
-
});
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
Middleware.prototype._serveSnapshot = function(request, response) {
|
|
42
|
-
var cachePath = this._getCachePathForRequest(request);
|
|
43
|
-
fs.readFile(cachePath, function (err, data) {
|
|
44
|
-
if(err) throw err;
|
|
45
|
-
response.send(data.toString());
|
|
46
|
-
});
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
Middleware.prototype._createSnapshot = function(request) {
|
|
50
|
-
var self = this;
|
|
51
|
-
var deferred = Q.defer();
|
|
52
|
-
var cachePath = this._getCachePathForRequest(request);
|
|
53
|
-
|
|
54
|
-
var url = request.protocol + '://' + request.get('host') + request.originalUrl;
|
|
55
|
-
this._ensureCacheDirectoryExists()
|
|
56
|
-
.then(function () { return self._getContentForURL(url); })
|
|
57
|
-
.then(function (content) {
|
|
58
|
-
fs.writeFile(cachePath, content, function (err) {
|
|
59
|
-
if (err) return deferred.reject(err);
|
|
60
|
-
deferred.resolve();
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
return deferred.promise;
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
Middleware.prototype._checkForCache = function (request) {
|
|
67
|
-
var deferred = Q.defer();
|
|
68
|
-
|
|
69
|
-
// Does a cache file exist for this hash?
|
|
70
|
-
var cachePath = this._getCachePathForRequest(request);
|
|
71
|
-
fs.exists(cachePath, function (exists) {
|
|
72
|
-
deferred.resolve(exists);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
return deferred.promise;
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
Middleware.prototype._getCachePathForRequest = function(request) {
|
|
79
|
-
var requestedPath = request.path;
|
|
80
|
-
var requestedHost = request.host;
|
|
81
|
-
var hash = this._getHashForUrl(requestedHost, requestedPath);
|
|
82
|
-
|
|
83
|
-
return path.resolve(this.options.cacheDirectory, hash);
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
Middleware.prototype._ensureCacheDirectoryExists = function() {
|
|
87
|
-
var self = this;
|
|
88
|
-
var deferred = Q.defer();
|
|
89
|
-
fs.exists(this.options.cacheDirectory, function (exists) {
|
|
90
|
-
if (!exists) {
|
|
91
|
-
fs.mkdir(self.options.cacheDirectory, function (err) {
|
|
92
|
-
if (err) return deferred.reject(err);
|
|
93
|
-
deferred.resolve();
|
|
94
|
-
});
|
|
95
|
-
} else {
|
|
96
|
-
deferred.resolve();
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
return deferred.promise;
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Returns an MD5 hash for the given path
|
|
104
|
-
* @param {String} host
|
|
105
|
-
* @param {String} path
|
|
106
|
-
* @return {String}
|
|
107
|
-
* @private
|
|
108
|
-
*/
|
|
109
|
-
Middleware.prototype._getHashForUrl = function (host, path) {
|
|
110
|
-
return crypto.createHash('md5').update(host + '/' + path).digest('hex');
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Creates a PhantomJS instance and returns the computed DOM
|
|
115
|
-
* for the given URL
|
|
116
|
-
* @param {String} url
|
|
117
|
-
* @return {Promise}
|
|
118
|
-
* @private
|
|
119
|
-
*/
|
|
120
|
-
Middleware.prototype._getContentForURL = function (url) {
|
|
121
|
-
var deferred = Q.defer();
|
|
122
|
-
var self = this;
|
|
123
|
-
phantom.create(function (ph) {
|
|
124
|
-
ph.onError = function (err) {
|
|
125
|
-
ph.exit();
|
|
126
|
-
return deferred.reject(err);
|
|
127
|
-
};
|
|
128
|
-
ph.createPage(function (page) {
|
|
129
|
-
page.onError = function (err) {
|
|
130
|
-
ph.exit();
|
|
131
|
-
return deferred.reject(err);
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
page.open(url, function (status) {
|
|
135
|
-
if (status !== 'success') {
|
|
136
|
-
return deferred.reject(new Error('PhantomJS status for ' + url + ': ' + status));
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
var generateHTML = function () {
|
|
140
|
-
page.evaluate(function() { return document.documentElement.outerHTML; }, function (result) {
|
|
141
|
-
ph.exit();
|
|
142
|
-
deferred.resolve(result);
|
|
143
|
-
});
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
if (typeof self.options.pageModifier === 'function') {
|
|
147
|
-
self.options.pageModifier(page, function () {
|
|
148
|
-
generateHTML();
|
|
149
|
-
});
|
|
150
|
-
} else {
|
|
151
|
-
generateHTML();
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
return deferred.promise;
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
module.exports = Middleware;
|
package/lib/seo.js
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
var path = require('path');
|
|
4
|
-
var _ = require('underscore');
|
|
5
|
-
var Middleware = require('./middleware');
|
|
6
|
-
|
|
7
|
-
function SEO(options) {
|
|
8
|
-
this.options = options;
|
|
9
|
-
this.options = _.defaults(this.options, {
|
|
10
|
-
cacheDirectory: path.resolve(process.cwd(), '.seo-cache')
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
if (!this.options.routes) {
|
|
14
|
-
this.options.routes = require(path.resolve(process.cwd(), 'seo-routes'));
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
this.middleware = new Middleware(this.options);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Returns the middleware
|
|
22
|
-
* @return {Function}
|
|
23
|
-
* @public
|
|
24
|
-
*/
|
|
25
|
-
SEO.prototype.init = function() {
|
|
26
|
-
var middleware = this.middleware;
|
|
27
|
-
return function() {
|
|
28
|
-
middleware.request.apply(middleware, arguments);
|
|
29
|
-
};
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
module.exports = SEO;
|
package/test/init.js
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
process.env.NODE_ENV = 'test';
|
|
3
|
-
|
|
4
|
-
var path = require('path');
|
|
5
|
-
var fs = require('fs');
|
|
6
|
-
var _ = require('underscore');
|
|
7
|
-
var express = require('express');
|
|
8
|
-
var seo = require('../index');
|
|
9
|
-
|
|
10
|
-
global._after = function (t, f) { return setTimeout(f, t); };
|
|
11
|
-
global._every = function (t, f) { return setInterval(f, t); };
|
|
12
|
-
|
|
13
|
-
global.createServer = function(options) {
|
|
14
|
-
var app = {};
|
|
15
|
-
var _seo = new seo(options);
|
|
16
|
-
|
|
17
|
-
app.server = express();
|
|
18
|
-
app.seo = _seo;
|
|
19
|
-
app.server.use(_seo.init());
|
|
20
|
-
|
|
21
|
-
app.server.get('/seo-route', function (req, res) {
|
|
22
|
-
var seoPagePath = path.resolve(process.cwd(), 'test/seo-page.html');
|
|
23
|
-
res.end(fs.readFileSync(seoPagePath));
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
app.server = app.server.listen(8899);
|
|
27
|
-
|
|
28
|
-
return app;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
global.clearCache = function() {
|
|
32
|
-
var directory = path.resolve(process.cwd(), '.seo-cache');
|
|
33
|
-
|
|
34
|
-
if (!fs.existsSync(directory)) return;
|
|
35
|
-
|
|
36
|
-
var files = fs.readdirSync(directory);
|
|
37
|
-
files.forEach(function (file) {
|
|
38
|
-
var filePath = directory + '/' + file;
|
|
39
|
-
if (!fs.statSync(filePath).isDirectory()) {
|
|
40
|
-
fs.unlinkSync(filePath);
|
|
41
|
-
}
|
|
42
|
-
});
|
|
43
|
-
};
|