@houtini/seo-audit-console 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.
Files changed (166) hide show
  1. package/LICENSE +92 -0
  2. package/README.md +211 -0
  3. package/dist/audit/checks.d.ts +35 -0
  4. package/dist/audit/checks.d.ts.map +1 -0
  5. package/dist/audit/checks.js +1475 -0
  6. package/dist/audit/checks.js.map +1 -0
  7. package/dist/audit/drift.d.ts +40 -0
  8. package/dist/audit/drift.d.ts.map +1 -0
  9. package/dist/audit/drift.js +148 -0
  10. package/dist/audit/drift.js.map +1 -0
  11. package/dist/audit/engine.d.ts +33 -0
  12. package/dist/audit/engine.d.ts.map +1 -0
  13. package/dist/audit/engine.js +186 -0
  14. package/dist/audit/engine.js.map +1 -0
  15. package/dist/audit/opportunities.d.ts +23 -0
  16. package/dist/audit/opportunities.d.ts.map +1 -0
  17. package/dist/audit/opportunities.js +149 -0
  18. package/dist/audit/opportunities.js.map +1 -0
  19. package/dist/audit/report.d.ts +11 -0
  20. package/dist/audit/report.d.ts.map +1 -0
  21. package/dist/audit/report.js +59 -0
  22. package/dist/audit/report.js.map +1 -0
  23. package/dist/audit/schema-validate.d.ts +35 -0
  24. package/dist/audit/schema-validate.d.ts.map +1 -0
  25. package/dist/audit/schema-validate.js +293 -0
  26. package/dist/audit/schema-validate.js.map +1 -0
  27. package/dist/audit/templates.d.ts +43 -0
  28. package/dist/audit/templates.d.ts.map +1 -0
  29. package/dist/audit/templates.js +129 -0
  30. package/dist/audit/templates.js.map +1 -0
  31. package/dist/audit/topicGaps.d.ts +42 -0
  32. package/dist/audit/topicGaps.d.ts.map +1 -0
  33. package/dist/audit/topicGaps.js +181 -0
  34. package/dist/audit/topicGaps.js.map +1 -0
  35. package/dist/core/AuditDatabase.d.ts +33 -0
  36. package/dist/core/AuditDatabase.d.ts.map +1 -0
  37. package/dist/core/AuditDatabase.js +481 -0
  38. package/dist/core/AuditDatabase.js.map +1 -0
  39. package/dist/core/Backlinks.d.ts +33 -0
  40. package/dist/core/Backlinks.d.ts.map +1 -0
  41. package/dist/core/Backlinks.js +110 -0
  42. package/dist/core/Backlinks.js.map +1 -0
  43. package/dist/core/Crawler.d.ts +23 -0
  44. package/dist/core/Crawler.d.ts.map +1 -0
  45. package/dist/core/Crawler.js +588 -0
  46. package/dist/core/Crawler.js.map +1 -0
  47. package/dist/core/DataForSeoClient.d.ts +86 -0
  48. package/dist/core/DataForSeoClient.d.ts.map +1 -0
  49. package/dist/core/DataForSeoClient.js +232 -0
  50. package/dist/core/DataForSeoClient.js.map +1 -0
  51. package/dist/core/Entities.d.ts +23 -0
  52. package/dist/core/Entities.d.ts.map +1 -0
  53. package/dist/core/Entities.js +62 -0
  54. package/dist/core/Entities.js.map +1 -0
  55. package/dist/core/GscClient.d.ts +22 -0
  56. package/dist/core/GscClient.d.ts.map +1 -0
  57. package/dist/core/GscClient.js +93 -0
  58. package/dist/core/GscClient.js.map +1 -0
  59. package/dist/core/GscSync.d.ts +20 -0
  60. package/dist/core/GscSync.d.ts.map +1 -0
  61. package/dist/core/GscSync.js +133 -0
  62. package/dist/core/GscSync.js.map +1 -0
  63. package/dist/core/JobManager.d.ts +30 -0
  64. package/dist/core/JobManager.d.ts.map +1 -0
  65. package/dist/core/JobManager.js +68 -0
  66. package/dist/core/JobManager.js.map +1 -0
  67. package/dist/core/RankTracker.d.ts +25 -0
  68. package/dist/core/RankTracker.d.ts.map +1 -0
  69. package/dist/core/RankTracker.js +78 -0
  70. package/dist/core/RankTracker.js.map +1 -0
  71. package/dist/core/Refresh.d.ts +32 -0
  72. package/dist/core/Refresh.d.ts.map +1 -0
  73. package/dist/core/Refresh.js +73 -0
  74. package/dist/core/Refresh.js.map +1 -0
  75. package/dist/core/UrlInspector.d.ts +22 -0
  76. package/dist/core/UrlInspector.d.ts.map +1 -0
  77. package/dist/core/UrlInspector.js +92 -0
  78. package/dist/core/UrlInspector.js.map +1 -0
  79. package/dist/core/WikidataClient.d.ts +19 -0
  80. package/dist/core/WikidataClient.d.ts.map +1 -0
  81. package/dist/core/WikidataClient.js +59 -0
  82. package/dist/core/WikidataClient.js.map +1 -0
  83. package/dist/core/agentReadiness.d.ts +34 -0
  84. package/dist/core/agentReadiness.d.ts.map +1 -0
  85. package/dist/core/agentReadiness.js +119 -0
  86. package/dist/core/agentReadiness.js.map +1 -0
  87. package/dist/core/ctrModel.d.ts +2 -0
  88. package/dist/core/ctrModel.d.ts.map +1 -0
  89. package/dist/core/ctrModel.js +6 -0
  90. package/dist/core/ctrModel.js.map +1 -0
  91. package/dist/core/dashboardData.d.ts +312 -0
  92. package/dist/core/dashboardData.d.ts.map +1 -0
  93. package/dist/core/dashboardData.js +550 -0
  94. package/dist/core/dashboardData.js.map +1 -0
  95. package/dist/core/dataStorage.d.ts +42 -0
  96. package/dist/core/dataStorage.d.ts.map +1 -0
  97. package/dist/core/dataStorage.js +193 -0
  98. package/dist/core/dataStorage.js.map +1 -0
  99. package/dist/core/draftBrief.d.ts +27 -0
  100. package/dist/core/draftBrief.d.ts.map +1 -0
  101. package/dist/core/draftBrief.js +69 -0
  102. package/dist/core/draftBrief.js.map +1 -0
  103. package/dist/core/extract.d.ts +67 -0
  104. package/dist/core/extract.d.ts.map +1 -0
  105. package/dist/core/extract.js +262 -0
  106. package/dist/core/extract.js.map +1 -0
  107. package/dist/core/gscFreshness.d.ts +18 -0
  108. package/dist/core/gscFreshness.d.ts.map +1 -0
  109. package/dist/core/gscFreshness.js +32 -0
  110. package/dist/core/gscFreshness.js.map +1 -0
  111. package/dist/core/linkGraph.d.ts +19 -0
  112. package/dist/core/linkGraph.d.ts.map +1 -0
  113. package/dist/core/linkGraph.js +125 -0
  114. package/dist/core/linkGraph.js.map +1 -0
  115. package/dist/core/passageScore.d.ts +19 -0
  116. package/dist/core/passageScore.d.ts.map +1 -0
  117. package/dist/core/passageScore.js +59 -0
  118. package/dist/core/passageScore.js.map +1 -0
  119. package/dist/core/paths.d.ts +5 -0
  120. package/dist/core/paths.d.ts.map +1 -0
  121. package/dist/core/paths.js +15 -0
  122. package/dist/core/paths.js.map +1 -0
  123. package/dist/core/queryData.d.ts +35 -0
  124. package/dist/core/queryData.d.ts.map +1 -0
  125. package/dist/core/queryData.js +200 -0
  126. package/dist/core/queryData.js.map +1 -0
  127. package/dist/core/reranker.d.ts +8 -0
  128. package/dist/core/reranker.d.ts.map +1 -0
  129. package/dist/core/reranker.js +68 -0
  130. package/dist/core/reranker.js.map +1 -0
  131. package/dist/core/robots.d.ts +11 -0
  132. package/dist/core/robots.d.ts.map +1 -0
  133. package/dist/core/robots.js +76 -0
  134. package/dist/core/robots.js.map +1 -0
  135. package/dist/core/sitemap.d.ts +15 -0
  136. package/dist/core/sitemap.d.ts.map +1 -0
  137. package/dist/core/sitemap.js +100 -0
  138. package/dist/core/sitemap.js.map +1 -0
  139. package/dist/core/sql.d.ts +6 -0
  140. package/dist/core/sql.d.ts.map +1 -0
  141. package/dist/core/sql.js +6 -0
  142. package/dist/core/sql.js.map +1 -0
  143. package/dist/core/types.d.ts +22 -0
  144. package/dist/core/types.d.ts.map +1 -0
  145. package/dist/core/types.js +2 -0
  146. package/dist/core/types.js.map +1 -0
  147. package/dist/core/url-key.d.ts +39 -0
  148. package/dist/core/url-key.d.ts.map +1 -0
  149. package/dist/core/url-key.js +106 -0
  150. package/dist/core/url-key.js.map +1 -0
  151. package/dist/generators/index.d.ts +45 -0
  152. package/dist/generators/index.d.ts.map +1 -0
  153. package/dist/generators/index.js +184 -0
  154. package/dist/generators/index.js.map +1 -0
  155. package/dist/index.d.ts +3 -0
  156. package/dist/index.d.ts.map +1 -0
  157. package/dist/index.js +10 -0
  158. package/dist/index.js.map +1 -0
  159. package/dist/server.d.ts +7 -0
  160. package/dist/server.d.ts.map +1 -0
  161. package/dist/server.js +1387 -0
  162. package/dist/server.js.map +1 -0
  163. package/dist/src/ui/dashboard.html +347 -0
  164. package/dist/src/ui/sync-progress.html +104 -0
  165. package/package.json +101 -0
  166. package/server.json +57 -0
@@ -0,0 +1,481 @@
1
+ /**
2
+ * AuditDatabase — one SQLite database per GSC property, colocating:
3
+ * - GSC history (search_analytics) ← from better-search-console
4
+ * - crawl snapshot (crawl_metadata, pages, links, errors) ← from seo-crawler-mcp
5
+ * - audit results (audit_runs, findings) ← new
6
+ *
7
+ * The join spine is the normalised URL key (`page_key` / `url_key`), written by
8
+ * url-key.ts on both the GSC and crawl write paths. See plan/architecture §2.
9
+ *
10
+ * Never console.log — MCP speaks JSON-RPC over stdio; use console.error for debug.
11
+ */
12
+ import Database from 'better-sqlite3';
13
+ import { mkdirSync } from 'node:fs';
14
+ import { dirname } from 'node:path';
15
+ export class AuditDatabase {
16
+ db;
17
+ constructor(dbPath) {
18
+ mkdirSync(dirname(dbPath), { recursive: true });
19
+ this.db = new Database(dbPath);
20
+ this.db.pragma('journal_mode = WAL');
21
+ this.db.pragma('synchronous = NORMAL'); // WAL + NORMAL: durable enough, far fewer fsyncs
22
+ this.db.pragma('busy_timeout = 10000'); // wait for locks instead of throwing SQLITE_BUSY (concurrent access)
23
+ this.db.pragma('foreign_keys = ON');
24
+ // Performance: large sites have millions of search_analytics rows and the dashboard runs ~20
25
+ // GROUP-BY scans over them. A big page cache + memory-mapped I/O keeps the hot table resident
26
+ // and runs GROUP BY temp b-trees in RAM — big wins on the 1M+ row properties, no accuracy cost.
27
+ this.db.pragma('cache_size = -262144'); // 256 MB page cache (negative = KiB)
28
+ this.db.pragma('mmap_size = 536870912'); // 512 MB memory-mapped I/O
29
+ this.db.pragma('temp_store = MEMORY'); // GROUP BY / ORDER BY scratch in RAM, not on disk
30
+ this.initializeTables();
31
+ }
32
+ initializeTables() {
33
+ // ── Property + sync/crawl bookkeeping ──────────────────────────────────
34
+ this.db.exec(`
35
+ CREATE TABLE IF NOT EXISTS property_meta (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ site_url TEXT UNIQUE NOT NULL,
38
+ host_form TEXT, -- 'apex' | 'www' | 'asis' (for url-key)
39
+ permission_level TEXT,
40
+ dfs_location_code INTEGER, -- DataForSEO location (e.g. 2036 AU, 2826 UK, 2840 US)
41
+ dfs_location_name TEXT, -- or a name, e.g. 'Australia'
42
+ last_synced_at TEXT,
43
+ last_crawl_id TEXT,
44
+ created_at TEXT DEFAULT (datetime('now'))
45
+ );
46
+ `);
47
+ // ── GSC history (from better-search-console) + page_key for the join ───
48
+ this.db.exec(`
49
+ CREATE TABLE IF NOT EXISTS search_analytics (
50
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ date TEXT NOT NULL,
52
+ query TEXT,
53
+ page TEXT,
54
+ page_key TEXT, -- normalised join key (urlKey(page))
55
+ device TEXT,
56
+ country TEXT,
57
+ search_appearance TEXT,
58
+ clicks INTEGER NOT NULL DEFAULT 0,
59
+ impressions INTEGER NOT NULL DEFAULT 0,
60
+ ctr REAL NOT NULL DEFAULT 0,
61
+ position REAL NOT NULL DEFAULT 0,
62
+ created_at TEXT DEFAULT (datetime('now'))
63
+ );
64
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_sa_unique
65
+ ON search_analytics(date, query, page, device, country);
66
+ CREATE INDEX IF NOT EXISTS idx_sa_date ON search_analytics(date);
67
+ CREATE INDEX IF NOT EXISTS idx_sa_query ON search_analytics(query);
68
+ CREATE INDEX IF NOT EXISTS idx_sa_page_key ON search_analytics(page_key);
69
+ CREATE INDEX IF NOT EXISTS idx_sa_date_pagekey ON search_analytics(date, page_key, clicks, impressions, position);
70
+ CREATE INDEX IF NOT EXISTS idx_sa_query_date ON search_analytics(query, date);
71
+ CREATE INDEX IF NOT EXISTS idx_sa_date_metrics ON search_analytics(date, clicks, impressions, ctr, position);
72
+ -- Covering indexes for the windowed GROUP BYs in the merged checks (page_key / query+page_key),
73
+ -- in group-key order so SQLite can aggregate without a full re-sort on big GSC tables.
74
+ CREATE INDEX IF NOT EXISTS idx_sa_pk_date ON search_analytics(page_key, date, clicks, impressions, position);
75
+ CREATE INDEX IF NOT EXISTS idx_sa_q_pk_date ON search_analytics(query, page_key, date, impressions, clicks, position);
76
+
77
+ -- Dashboard payload cache: the dashboard runs ~20 GROUP-BY scans over search_analytics, which
78
+ -- is ~6s on a 1.8M-row property. The result only changes on sync/audit, so we cache the JSON
79
+ -- keyed by a cheap data-version and serve repeat views/exports instantly (accuracy preserved:
80
+ -- any data change bumps the version and forces a recompute). Single row (id=1).
81
+ CREATE TABLE IF NOT EXISTS dashboard_cache (
82
+ id INTEGER PRIMARY KEY CHECK (id = 1),
83
+ version TEXT NOT NULL,
84
+ payload TEXT NOT NULL,
85
+ created_at TEXT NOT NULL
86
+ );
87
+ `);
88
+ // ── Crawl snapshot (from seo-crawler-mcp), url_key added, redirects captured ─
89
+ this.db.exec(`
90
+ CREATE TABLE IF NOT EXISTS crawl_metadata (
91
+ crawl_id TEXT PRIMARY KEY,
92
+ base_url TEXT NOT NULL,
93
+ base_domain TEXT NOT NULL,
94
+ status TEXT DEFAULT 'running', -- running | completed | failed | interrupted | cancelled
95
+ max_depth INTEGER,
96
+ max_pages INTEGER,
97
+ user_agent TEXT,
98
+ rendered INTEGER DEFAULT 0, -- 0 = HTTP only, 1 = render tier used
99
+ urls_discovered INTEGER DEFAULT 0,
100
+ urls_crawled INTEGER DEFAULT 0,
101
+ urls_failed INTEGER DEFAULT 0,
102
+ urls_skipped INTEGER DEFAULT 0,
103
+ started_at TEXT,
104
+ finished_at TEXT,
105
+ duration_ms INTEGER,
106
+ error TEXT
107
+ );
108
+ `);
109
+ this.db.exec(`
110
+ CREATE TABLE IF NOT EXISTS pages (
111
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
112
+ crawl_id TEXT NOT NULL,
113
+ url TEXT NOT NULL,
114
+ url_key TEXT NOT NULL, -- urlKey(url) — the join key
115
+ status_code INTEGER,
116
+ content_type TEXT,
117
+ content_encoding TEXT, -- br/gzip (compression war-story W6)
118
+ cache_control TEXT,
119
+ last_modified TEXT,
120
+ etag TEXT,
121
+ vary TEXT, -- Vary:User-Agent war-story (#54)
122
+ bytes INTEGER, -- transfer/decoded size (excessive-resource check)
123
+ response_time_ms INTEGER, -- TTFB proxy
124
+ depth INTEGER,
125
+ is_internal INTEGER,
126
+ indexable INTEGER, -- derived: 200 + HTML + not noindex + self-canonical
127
+ indexable_reason TEXT, -- why NOT indexable: http-404/http-500/noindex-meta/noindex-header/canonicalised/non-html/robots-disallowed/redirect
128
+ noindex INTEGER,
129
+ title TEXT,
130
+ title_length INTEGER,
131
+ meta_description TEXT,
132
+ meta_description_length INTEGER,
133
+ h1 TEXT,
134
+ h1_count INTEGER,
135
+ word_count INTEGER,
136
+ lang TEXT,
137
+ charset TEXT,
138
+ canonical_url TEXT,
139
+ canonical_key TEXT, -- urlKey(canonical_url) for D5 canonical-conflict
140
+ robots TEXT,
141
+ x_robots_tag TEXT,
142
+ viewport TEXT,
143
+ json_ld TEXT, -- raw JSON-LD blocks (schema validation)
144
+ og_tags TEXT,
145
+ hreflang TEXT,
146
+ redirects TEXT, -- captured 3xx chain (crawler bug fix)
147
+ internal_links INTEGER,
148
+ external_links INTEGER,
149
+ image_count INTEGER DEFAULT 0, -- <img> count (image-alt check)
150
+ images_without_alt INTEGER DEFAULT 0, -- <img> lacking non-empty alt
151
+ images_missing_dimensions INTEGER DEFAULT 0, -- <img> lacking width&height (CLS)
152
+ canonical_count INTEGER DEFAULT 0, -- # rel=canonical tags (multiple-canonical)
153
+ canonical_relative INTEGER DEFAULT 0, -- canonical declared as a relative URL
154
+ h2_count INTEGER DEFAULT 0,
155
+ heading_skips INTEGER DEFAULT 0, -- skipped heading levels (h1→h3)
156
+ rel_next INTEGER DEFAULT 0,
157
+ rel_prev INTEGER DEFAULT 0,
158
+ rel_amphtml TEXT, -- declared AMP version URL
159
+ mixed_content_count INTEGER DEFAULT 0, -- http subresources on https page
160
+ twitter_tags TEXT, -- JSON of twitter:* meta
161
+ has_microdata INTEGER DEFAULT 0,
162
+ has_rdfa INTEGER DEFAULT 0,
163
+ has_favicon INTEGER, -- link rel icon present (NULL = crawled before this was captured)
164
+ has_analytics INTEGER, -- client-side analytics/tag-manager snippet detected (NULL = pre-feature crawl)
165
+ body_chunks TEXT, -- JSON [{heading,level,text}] — content segmented by heading (RAG layer)
166
+ max_passage_score REAL, -- cross-encoder best-passage relevance vs top GSC query (score_passages)
167
+ max_passage_query TEXT, -- the query that best-passage score was computed against
168
+ max_passage_impr INTEGER, -- that query's impressions at scoring time (for priority)
169
+ max_passage_at TEXT, -- when scored
170
+ inlink_count INTEGER DEFAULT 0, -- in-degree (computed post-crawl: orphans)
171
+ ipr REAL DEFAULT 0, -- internal PageRank 0–100 (computed post-crawl)
172
+ click_depth INTEGER, -- body-only shortest path from home (post-crawl; null=unreachable via body)
173
+ rendered INTEGER DEFAULT 0, -- did this row come from the render tier?
174
+ render_diff TEXT, -- raw-vs-rendered diff summary (JSON), if rendered
175
+ security_headers TEXT, -- {csp,hsts,xFrame,referrer,...} JSON
176
+ crawled_at TEXT DEFAULT (datetime('now')),
177
+ FOREIGN KEY (crawl_id) REFERENCES crawl_metadata(crawl_id)
178
+ );
179
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_pages_urlkey ON pages(url_key);
180
+ CREATE INDEX IF NOT EXISTS idx_pages_status ON pages(status_code);
181
+ CREATE INDEX IF NOT EXISTS idx_pages_depth ON pages(depth);
182
+ CREATE INDEX IF NOT EXISTS idx_pages_indexable ON pages(indexable);
183
+ CREATE INDEX IF NOT EXISTS idx_pages_ipr ON pages(ipr DESC);
184
+ `);
185
+ // ── Change-detection (drift): one persistent snapshot per (crawl, URL) of the SEO-salient
186
+ // fields. `pages` only ever holds the latest crawl (unique url_key), so cross-crawl diffing
187
+ // needs its own history table. Written once per crawl at audit time (INSERT OR IGNORE). ──
188
+ this.db.exec(`
189
+ CREATE TABLE IF NOT EXISTS page_snapshots (
190
+ crawl_id TEXT NOT NULL,
191
+ url_key TEXT NOT NULL,
192
+ captured_at TEXT NOT NULL,
193
+ status_code INTEGER,
194
+ indexable INTEGER,
195
+ indexable_reason TEXT,
196
+ title TEXT,
197
+ meta_description TEXT,
198
+ h1 TEXT,
199
+ canonical_key TEXT,
200
+ robots TEXT,
201
+ x_robots_tag TEXT,
202
+ word_count INTEGER,
203
+ schema_types TEXT, -- sorted distinct JSON-LD @types, comma-joined
204
+ PRIMARY KEY (crawl_id, url_key)
205
+ );
206
+ CREATE INDEX IF NOT EXISTS idx_snap_urlkey ON page_snapshots(url_key);
207
+ CREATE INDEX IF NOT EXISTS idx_snap_captured ON page_snapshots(captured_at);
208
+ `);
209
+ // Agent-readiness result (one row per origin) — populated by check_agent_readiness, read by
210
+ // the dashboard. Live HTTP probe, not crawl-derived, so it persists separately.
211
+ this.db.exec(`
212
+ CREATE TABLE IF NOT EXISTS agent_readiness (
213
+ origin TEXT PRIMARY KEY,
214
+ score INTEGER,
215
+ level TEXT,
216
+ checks TEXT, -- JSON [{id,category,label,present,detail,fix}]
217
+ by_category TEXT, -- JSON [{category,passed,total}]
218
+ checked_at TEXT
219
+ );
220
+ `);
221
+ this.db.exec(`
222
+ CREATE TABLE IF NOT EXISTS links (
223
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
224
+ crawl_id TEXT NOT NULL,
225
+ source_url TEXT NOT NULL,
226
+ source_key TEXT NOT NULL,
227
+ target_url TEXT NOT NULL,
228
+ target_key TEXT NOT NULL, -- join key both ends → fixes false orphans
229
+ anchor_text TEXT,
230
+ is_internal INTEGER,
231
+ placement TEXT, -- navigation | footer | body
232
+ rel TEXT,
233
+ FOREIGN KEY (crawl_id) REFERENCES crawl_metadata(crawl_id)
234
+ );
235
+ CREATE INDEX IF NOT EXISTS idx_links_source ON links(source_key);
236
+ CREATE INDEX IF NOT EXISTS idx_links_target ON links(target_key);
237
+ CREATE INDEX IF NOT EXISTS idx_links_internal ON links(is_internal);
238
+ CREATE INDEX IF NOT EXISTS idx_links_internal_placement ON links(is_internal, placement, target_key);
239
+ `);
240
+ this.db.exec(`
241
+ CREATE TABLE IF NOT EXISTS errors (
242
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
243
+ crawl_id TEXT NOT NULL,
244
+ url TEXT NOT NULL,
245
+ error_type TEXT,
246
+ error_message TEXT,
247
+ created_at TEXT DEFAULT (datetime('now')),
248
+ FOREIGN KEY (crawl_id) REFERENCES crawl_metadata(crawl_id)
249
+ );
250
+ CREATE INDEX IF NOT EXISTS idx_errors_type ON errors(error_type);
251
+ `);
252
+ // ── GSC URL Inspection (authoritative per-URL index status) ─────────────
253
+ this.db.exec(`
254
+ CREATE TABLE IF NOT EXISTS url_inspection (
255
+ url_key TEXT PRIMARY KEY,
256
+ url TEXT NOT NULL,
257
+ verdict TEXT,
258
+ coverage_state TEXT,
259
+ indexing_state TEXT,
260
+ robots_txt_state TEXT,
261
+ page_fetch_state TEXT,
262
+ last_crawl_time TEXT,
263
+ google_canonical TEXT, -- Google-selected canonical
264
+ user_canonical TEXT, -- declared canonical (mismatch = D5 finding)
265
+ crawled_as TEXT,
266
+ mobile_usability TEXT,
267
+ rich_results TEXT, -- JSON of detected rich-result types/issues
268
+ inspected_at TEXT DEFAULT (datetime('now'))
269
+ );
270
+ CREATE INDEX IF NOT EXISTS idx_inspection_coverage ON url_inspection(coverage_state);
271
+ `);
272
+ // ── DataForSEO rank history (over-time sequence, monthly) ───────────────
273
+ this.db.exec(`
274
+ CREATE TABLE IF NOT EXISTS rank_history (
275
+ period TEXT PRIMARY KEY, -- YYYY-MM
276
+ pos_1_3 INTEGER DEFAULT 0,
277
+ pos_4_10 INTEGER DEFAULT 0,
278
+ pos_11_20 INTEGER DEFAULT 0,
279
+ pos_21_100 INTEGER DEFAULT 0,
280
+ etv REAL DEFAULT 0, -- estimated organic traffic value
281
+ keyword_count INTEGER DEFAULT 0, -- total ranking keywords (not 'count' — clashes with COUNT())
282
+ source TEXT,
283
+ fetched_at TEXT DEFAULT (datetime('now'))
284
+ );
285
+ `);
286
+ // ── Audit results (new) ────────────────────────────────────────────────
287
+ this.db.exec(`
288
+ CREATE TABLE IF NOT EXISTS audit_runs (
289
+ run_id TEXT PRIMARY KEY,
290
+ crawl_id TEXT,
291
+ scope TEXT, -- core | full
292
+ gsc_window_start TEXT,
293
+ gsc_window_end TEXT,
294
+ integrity_ok INTEGER, -- crawl-integrity gates passed?
295
+ started_at TEXT,
296
+ finished_at TEXT,
297
+ finding_count INTEGER DEFAULT 0
298
+ );
299
+ `);
300
+ this.db.exec(`
301
+ CREATE TABLE IF NOT EXISTS findings (
302
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
303
+ run_id TEXT NOT NULL,
304
+ check_id TEXT NOT NULL,
305
+ category TEXT, -- integrity|crawlability|indexation|onpage|schema|performance|war-stories|agentic|merged
306
+ severity TEXT, -- crit|high|med|low|info
307
+ labels TEXT, -- JSON array of D|N|L|G|S
308
+ certainty REAL, -- 1.0 deterministic, 0.5 judgement
309
+ url_key TEXT, -- affected page (null = site-wide)
310
+ evidence TEXT, -- JSON proof payload
311
+ traffic_at_risk TEXT, -- JSON {clicks,impressions,position,searchVolume}
312
+ effort TEXT, -- JSON {base,scaleModifier,fixType}
313
+ priority REAL, -- P = (S × C × V) / E
314
+ recommendation TEXT, -- JSON {text, generated?}
315
+ FOREIGN KEY (run_id) REFERENCES audit_runs(run_id)
316
+ );
317
+ CREATE INDEX IF NOT EXISTS idx_findings_run ON findings(run_id);
318
+ CREATE INDEX IF NOT EXISTS idx_findings_priority ON findings(priority DESC);
319
+ CREATE INDEX IF NOT EXISTS idx_findings_check ON findings(check_id);
320
+ `);
321
+ // ── Backlinks (DataForSEO, on-demand) — page-level counts + live HTTP status ──
322
+ this.db.exec(`
323
+ CREATE TABLE IF NOT EXISTS page_backlinks (
324
+ url_key TEXT PRIMARY KEY, -- normalised join key (urlKey of the page)
325
+ url TEXT NOT NULL, -- the page address DataForSEO reported
326
+ backlinks INTEGER DEFAULT 0,
327
+ referring_domains INTEGER DEFAULT 0,
328
+ dofollow INTEGER DEFAULT 0, -- dofollow backlink count
329
+ status_code INTEGER, -- live HTTP status of the backlinked page (null = unchecked)
330
+ fetched_at TEXT DEFAULT (datetime('now'))
331
+ );
332
+ CREATE INDEX IF NOT EXISTS idx_pbl_backlinks ON page_backlinks(backlinks DESC);
333
+ `);
334
+ // ── Sitemap URLs (for crawl↔sitemap reconciliation) ────────────────────
335
+ this.db.exec(`
336
+ CREATE TABLE IF NOT EXISTS sitemap_urls (
337
+ url_key TEXT PRIMARY KEY, -- normalised join key of a URL listed in the XML sitemap(s)
338
+ url TEXT NOT NULL,
339
+ lastmod TEXT, -- the sitemap's <lastmod> claim (freshness-honesty check)
340
+ fetched_at TEXT DEFAULT (datetime('now'))
341
+ );
342
+ `);
343
+ // ── Image weights (post-crawl sample: distinct <img> srcs + content-length) ─
344
+ this.db.exec(`
345
+ CREATE TABLE IF NOT EXISTS image_assets (
346
+ url TEXT PRIMARY KEY,
347
+ bytes INTEGER, -- content-length (body never downloaded)
348
+ status INTEGER,
349
+ content_type TEXT,
350
+ used_on INTEGER DEFAULT 1, -- how many crawled pages reference this image
351
+ fetched_at TEXT DEFAULT (datetime('now'))
352
+ );
353
+ `);
354
+ // ── DataForSEO on-demand enrichments persisted for the audit (6a) ────────
355
+ // Keyword intent (Labs search_intent) → powers intent-vs-pagetype-mismatch.
356
+ this.db.exec(`
357
+ CREATE TABLE IF NOT EXISTS keyword_intent (
358
+ keyword TEXT PRIMARY KEY,
359
+ intent TEXT, -- informational | navigational | commercial | transactional
360
+ probability REAL,
361
+ fetched_at TEXT DEFAULT (datetime('now'))
362
+ );
363
+ `);
364
+ // Per-URL lab Core Web Vitals (On-Page Lighthouse) → powers high-yield-cwv-fail.
365
+ this.db.exec(`
366
+ CREATE TABLE IF NOT EXISTS page_cwv (
367
+ url_key TEXT PRIMARY KEY, -- urlKey of the audited URL (join spine)
368
+ url TEXT NOT NULL,
369
+ for_mobile INTEGER DEFAULT 1,
370
+ performance REAL, -- Lighthouse performance score 0–1
371
+ lcp_ms REAL, -- largest-contentful-paint numericValue (ms)
372
+ cls REAL, -- cumulative-layout-shift numericValue
373
+ tbt_ms REAL, -- total-blocking-time numericValue (ms)
374
+ fetched_at TEXT DEFAULT (datetime('now'))
375
+ );
376
+ `);
377
+ // ── Wikidata entity layer (6d) — heuristic H1→QID resolution + relationship edges ──
378
+ this.db.exec(`
379
+ CREATE TABLE IF NOT EXISTS page_entity (
380
+ url_key TEXT PRIMARY KEY, -- page resolved to a Wikidata entity
381
+ qid TEXT NOT NULL, -- Wikidata QID (e.g. Q42)
382
+ label TEXT,
383
+ description TEXT,
384
+ source TEXT, -- 'h1' | 'title' | 'declared' (how we resolved)
385
+ fetched_at TEXT DEFAULT (datetime('now'))
386
+ );
387
+ CREATE INDEX IF NOT EXISTS idx_page_entity_qid ON page_entity(qid);
388
+ CREATE TABLE IF NOT EXISTS entity_edge (
389
+ qid TEXT NOT NULL, -- child / part entity
390
+ related_qid TEXT NOT NULL, -- parent / whole entity (P279 subclass-of / P361 part-of target)
391
+ relation TEXT NOT NULL, -- 'subclass_of' | 'part_of'
392
+ PRIMARY KEY (qid, related_qid, relation)
393
+ );
394
+ CREATE INDEX IF NOT EXISTS idx_entity_edge_related ON entity_edge(related_qid);
395
+ `);
396
+ this.migrate();
397
+ }
398
+ /** Idempotent column additions for DBs created before a column existed. */
399
+ migrate() {
400
+ const cols = (table) => new Set(this.db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name));
401
+ const ensure = (table, col, ddl) => {
402
+ if (!cols(table).has(col))
403
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
404
+ };
405
+ ensure('pages', 'image_count', 'image_count INTEGER DEFAULT 0');
406
+ ensure('pages', 'images_without_alt', 'images_without_alt INTEGER DEFAULT 0');
407
+ ensure('pages', 'images_missing_dimensions', 'images_missing_dimensions INTEGER DEFAULT 0');
408
+ ensure('pages', 'canonical_count', 'canonical_count INTEGER DEFAULT 0');
409
+ ensure('pages', 'canonical_relative', 'canonical_relative INTEGER DEFAULT 0');
410
+ ensure('pages', 'h2_count', 'h2_count INTEGER DEFAULT 0');
411
+ ensure('pages', 'heading_skips', 'heading_skips INTEGER DEFAULT 0');
412
+ ensure('pages', 'rel_next', 'rel_next INTEGER DEFAULT 0');
413
+ ensure('pages', 'rel_prev', 'rel_prev INTEGER DEFAULT 0');
414
+ ensure('pages', 'rel_amphtml', 'rel_amphtml TEXT');
415
+ ensure('pages', 'mixed_content_count', 'mixed_content_count INTEGER DEFAULT 0');
416
+ ensure('pages', 'twitter_tags', 'twitter_tags TEXT');
417
+ ensure('pages', 'has_microdata', 'has_microdata INTEGER DEFAULT 0');
418
+ ensure('pages', 'has_rdfa', 'has_rdfa INTEGER DEFAULT 0');
419
+ ensure('pages', 'body_chunks', 'body_chunks TEXT');
420
+ ensure('pages', 'has_favicon', 'has_favicon INTEGER');
421
+ ensure('pages', 'has_analytics', 'has_analytics INTEGER');
422
+ ensure('pages', 'conditional_304', 'conditional_304 INTEGER'); // 1 = honoured If-Modified-Since/If-None-Match with a 304; 0 = returned 200 anyway; NULL = not probed
423
+ ensure('sitemap_urls', 'lastmod', 'lastmod TEXT');
424
+ ensure('pages', 'max_passage_score', 'max_passage_score REAL');
425
+ ensure('pages', 'max_passage_query', 'max_passage_query TEXT');
426
+ ensure('pages', 'max_passage_impr', 'max_passage_impr INTEGER');
427
+ ensure('pages', 'max_passage_at', 'max_passage_at TEXT');
428
+ ensure('pages', 'click_depth', 'click_depth INTEGER');
429
+ ensure('pages', 'indexable_reason', 'indexable_reason TEXT');
430
+ // property-level backlink profile summary (from DataForSEO backlinks/summary)
431
+ ensure('property_meta', 'total_backlinks', 'total_backlinks INTEGER');
432
+ ensure('property_meta', 'referring_domains', 'referring_domains INTEGER');
433
+ ensure('property_meta', 'backlinks_spam_score', 'backlinks_spam_score REAL');
434
+ ensure('property_meta', 'backlinks_fetched_at', 'backlinks_fetched_at TEXT');
435
+ // backlinks/summary extras (same paid response): Domain Rank, equity leaks, nofollow share
436
+ ensure('property_meta', 'backlinks_rank', 'backlinks_rank INTEGER'); // DataForSEO Domain Rank (0–1000)
437
+ ensure('property_meta', 'broken_backlinks', 'broken_backlinks INTEGER');
438
+ ensure('property_meta', 'broken_pages', 'broken_pages INTEGER');
439
+ ensure('property_meta', 'backlinks_nofollow_pct', 'backlinks_nofollow_pct REAL'); // share of referring links marked nofollow (0–1)
440
+ // rank_history keyword-movement counts + paid-value — same historical_rank_overview response we already pay for
441
+ ensure('rank_history', 'is_new', 'is_new INTEGER');
442
+ ensure('rank_history', 'is_up', 'is_up INTEGER');
443
+ ensure('rank_history', 'is_down', 'is_down INTEGER');
444
+ ensure('rank_history', 'is_lost', 'is_lost INTEGER');
445
+ ensure('rank_history', 'estimated_paid_traffic_cost', 'estimated_paid_traffic_cost REAL');
446
+ // url_inspection: sample of referring pages Google reports for the URL (JSON array of URLs)
447
+ ensure('url_inspection', 'referring_urls', 'referring_urls TEXT');
448
+ // GSC search type ('web' | 'discover' | 'googleNews' | 'image' | 'video'). NOTE: the unique
449
+ // index idx_sa_unique deliberately does NOT include this column (rebuilding it on existing
450
+ // rows would rewrite months of history) — the table therefore holds ONE search type at a
451
+ // time, enforced by the mode-guard in GscSync (a type switch wipes, riding the first batch).
452
+ ensure('search_analytics', 'search_type', "search_type TEXT DEFAULT 'web'");
453
+ }
454
+ // ── Minimal property accessors (full CRUD added as modules are wired) ─────
455
+ upsertProperty(siteUrl, hostForm, permissionLevel) {
456
+ this.db
457
+ .prepare(`INSERT INTO property_meta (site_url, host_form, permission_level)
458
+ VALUES (?, ?, ?)
459
+ ON CONFLICT(site_url) DO UPDATE SET host_form = excluded.host_form,
460
+ permission_level = COALESCE(excluded.permission_level, property_meta.permission_level)`)
461
+ .run(siteUrl, hostForm, permissionLevel ?? null);
462
+ }
463
+ getProperty(siteUrl) {
464
+ return this.db.prepare('SELECT site_url, host_form, last_synced_at FROM property_meta WHERE site_url = ?').get(siteUrl);
465
+ }
466
+ /** Persist the DataForSEO target location for a property (string name or numeric code). */
467
+ setDfsLocation(siteUrl, location) {
468
+ this.db
469
+ .prepare('UPDATE property_meta SET dfs_location_code = ?, dfs_location_name = ? WHERE site_url = ?')
470
+ .run(typeof location === 'number' ? location : null, typeof location === 'string' ? location : null, siteUrl);
471
+ }
472
+ /** Resolved DataForSEO location for a property: a code, a name, or undefined. */
473
+ getDfsLocation(siteUrl) {
474
+ const r = this.db.prepare('SELECT dfs_location_code c, dfs_location_name n FROM property_meta WHERE site_url = ?').get(siteUrl);
475
+ return r?.c ?? r?.n ?? undefined;
476
+ }
477
+ close() {
478
+ this.db.close();
479
+ }
480
+ }
481
+ //# sourceMappingURL=AuditDatabase.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AuditDatabase.js","sourceRoot":"","sources":["../../src/core/AuditDatabase.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,33 @@
1
+ import { DataForSeoClient } from './DataForSeoClient.js';
2
+ export interface BacklinksResult {
3
+ siteUrl: string;
4
+ target: string;
5
+ pages: number;
6
+ statusChecked: number;
7
+ totalBacklinks: number;
8
+ referringDomains: number;
9
+ domainRank: number | null;
10
+ brokenBacklinks: number;
11
+ brokenPages: number;
12
+ nofollowPct: number | null;
13
+ cached: boolean;
14
+ cost: number;
15
+ }
16
+ /**
17
+ * On-demand backlink profile via DataForSEO (cheap endpoints): summary/live (overall
18
+ * profile → property_meta) + domain_pages_summary/live (per-page backlink counts →
19
+ * page_backlinks). Then resolves each backlinked page's live HTTP status — from the
20
+ * crawl when present, else a bounded fetch — so backlinks-to-404 is precise.
21
+ * Never auto-run (the user's rule: backlinks are paid + on-demand); 20-day cached.
22
+ */
23
+ export declare class Backlinks {
24
+ private readonly dfs;
25
+ private readonly dataDir;
26
+ constructor(dfs: DataForSeoClient, dataDir: string);
27
+ run(siteUrl: string, opts: {
28
+ limit?: number;
29
+ statusLimit?: number;
30
+ userAgent?: string;
31
+ }, update: (p: Record<string, unknown>) => void, signal: AbortSignal): Promise<BacklinksResult>;
32
+ }
33
+ //# sourceMappingURL=Backlinks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Backlinks.d.ts","sourceRoot":"","sources":["../../src/core/Backlinks.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAoBzD,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAC;IACtE,cAAc,EAAE,MAAM,CAAC;IAAC,gBAAgB,EAAE,MAAM,CAAC;IACjD,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,eAAe,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAC7C,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,MAAM,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;CAC/B;AAED;;;;;;GAMG;AACH,qBAAa,SAAS;IACR,OAAO,CAAC,QAAQ,CAAC,GAAG;IAAoB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAA/C,GAAG,EAAE,gBAAgB,EAAmB,OAAO,EAAE,MAAM;IAE9E,GAAG,CACP,OAAO,EAAE,MAAM,EACf,IAAI,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,EAClE,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,EAC5C,MAAM,EAAE,WAAW,GAClB,OAAO,CAAC,eAAe,CAAC;CA0F5B"}
@@ -0,0 +1,110 @@
1
+ import { AuditDatabase } from './AuditDatabase.js';
2
+ import { dbPathFor } from './paths.js';
3
+ import { urlKey, hostFormForProperty } from './url-key.js';
4
+ const n = (v) => Number(v) || 0;
5
+ const domainFromProperty = (siteUrl) => siteUrl.replace(/^sc-domain:/, '').replace(/^https?:\/\//, '').replace(/\/+$/, '');
6
+ /** Live HTTP status of a backlinked URL (follow redirects → final status; null on error). */
7
+ async function statusOf(url, ua) {
8
+ try {
9
+ const res = await fetch(url, {
10
+ method: 'GET', redirect: 'follow',
11
+ headers: { 'user-agent': ua, 'cache-control': 'no-cache' },
12
+ signal: AbortSignal.timeout(10000),
13
+ });
14
+ return res.status;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ /**
21
+ * On-demand backlink profile via DataForSEO (cheap endpoints): summary/live (overall
22
+ * profile → property_meta) + domain_pages_summary/live (per-page backlink counts →
23
+ * page_backlinks). Then resolves each backlinked page's live HTTP status — from the
24
+ * crawl when present, else a bounded fetch — so backlinks-to-404 is precise.
25
+ * Never auto-run (the user's rule: backlinks are paid + on-demand); 20-day cached.
26
+ */
27
+ export class Backlinks {
28
+ dfs;
29
+ dataDir;
30
+ constructor(dfs, dataDir) {
31
+ this.dfs = dfs;
32
+ this.dataDir = dataDir;
33
+ }
34
+ async run(siteUrl, opts, update, signal) {
35
+ const target = domainFromProperty(siteUrl);
36
+ const hostForm = hostFormForProperty(siteUrl);
37
+ const ua = opts.userAgent ?? 'seo-audit-console';
38
+ const db = new AuditDatabase(dbPathFor(this.dataDir, siteUrl));
39
+ try {
40
+ db.upsertProperty(siteUrl, hostForm ?? 'asis');
41
+ // 1. Profile summary → property_meta
42
+ const sum = await this.dfs.backlinksSummary(target);
43
+ // Surface task-level failures (the Backlinks API is a SEPARATE DataForSEO subscription —
44
+ // a 40204 here means it isn't activated). Don't silently store 0 backlinks.
45
+ const task = sum.tasks[0];
46
+ if (!task || (task.status_code ?? 0) >= 40000) {
47
+ const msg = task?.status_message ?? 'no task returned';
48
+ throw new Error(`DataForSEO Backlinks unavailable: ${msg}` +
49
+ (task?.status_code === 40204 ? ' — the Backlinks API is a separate DataForSEO subscription; activate it at app.dataforseo.com/backlinks-subscription to use pull_backlinks.' : ''));
50
+ }
51
+ const s = task.result?.[0] ?? {};
52
+ // Summary extras previously dropped from the same paid response: Domain Rank (the authority
53
+ // number), broken backlinks/pages (equity leaks Google's link graph sees), nofollow share.
54
+ const nofollowPct = n(s.backlinks) > 0 ? n(s.referring_links_attributes?.nofollow) / n(s.backlinks) : null;
55
+ db.db.prepare(`UPDATE property_meta SET total_backlinks=?, referring_domains=?, backlinks_spam_score=?,
56
+ backlinks_rank=?, broken_backlinks=?, broken_pages=?, backlinks_nofollow_pct=?,
57
+ backlinks_fetched_at=datetime('now') WHERE site_url=?`).run(n(s.backlinks), n(s.referring_domains), s.backlinks_spam_score ?? null, s.rank != null ? n(s.rank) : null, n(s.broken_backlinks), n(s.broken_pages), nofollowPct, siteUrl);
58
+ update({ phase: 'summary', backlinks: n(s.backlinks), referringDomains: n(s.referring_domains), rank: s.rank ?? null });
59
+ // 2. Per-page backlink counts → page_backlinks
60
+ const res = await this.dfs.domainPagesSummary(target, opts.limit ?? 1000);
61
+ const items = res.tasks[0]?.result?.[0]?.items ?? [];
62
+ const rows = items
63
+ .map(it => {
64
+ const url = it.url ?? it.page_address ?? it.page ?? '';
65
+ return {
66
+ url,
67
+ url_key: url ? urlKey(url, { hostForm }) : '',
68
+ backlinks: n(it.backlinks),
69
+ referring_domains: n(it.referring_domains ?? it.referring_main_domains),
70
+ dofollow: n(it.dofollow ?? it.backlinks_dofollow ?? it.referring_links_types?.anchor),
71
+ };
72
+ })
73
+ .filter(r => r.url && r.url_key);
74
+ // 3. Live status — reuse crawl status where we have it; fetch the rest (bounded, by backlinks).
75
+ const crawlStatus = new Map(db.db.prepare('SELECT url_key, status_code FROM pages WHERE status_code IS NOT NULL').all()
76
+ .map(r => [r.url_key, r.status_code]));
77
+ const toFetch = rows.filter(r => !crawlStatus.has(r.url_key)).sort((a, b) => b.backlinks - a.backlinks).slice(0, opts.statusLimit ?? 150);
78
+ const fetched = new Map();
79
+ let i = 0;
80
+ await Promise.all(Array.from({ length: 8 }, async () => {
81
+ while (i < toFetch.length && !signal.aborted) {
82
+ const r = toFetch[i++];
83
+ fetched.set(r.url_key, await statusOf(r.url, ua));
84
+ update({ phase: 'status', checked: fetched.size, total: toFetch.length });
85
+ }
86
+ }));
87
+ const upsert = db.db.prepare(`INSERT INTO page_backlinks (url_key, url, backlinks, referring_domains, dofollow, status_code, fetched_at)
88
+ VALUES (@url_key,@url,@backlinks,@referring_domains,@dofollow,@status_code,datetime('now'))
89
+ ON CONFLICT(url_key) DO UPDATE SET url=excluded.url, backlinks=excluded.backlinks,
90
+ referring_domains=excluded.referring_domains, dofollow=excluded.dofollow,
91
+ status_code=excluded.status_code, fetched_at=datetime('now')`);
92
+ db.db.transaction(() => {
93
+ for (const r of rows)
94
+ upsert.run({ ...r, status_code: crawlStatus.get(r.url_key) ?? fetched.get(r.url_key) ?? null });
95
+ })();
96
+ return {
97
+ siteUrl, target, pages: rows.length, statusChecked: fetched.size,
98
+ totalBacklinks: n(s.backlinks), referringDomains: n(s.referring_domains),
99
+ domainRank: s.rank != null ? n(s.rank) : null,
100
+ brokenBacklinks: n(s.broken_backlinks), brokenPages: n(s.broken_pages),
101
+ nofollowPct: nofollowPct != null ? Math.round(nofollowPct * 1000) / 1000 : null,
102
+ cached: sum.cached && res.cached, cost: sum.cost + res.cost,
103
+ };
104
+ }
105
+ finally {
106
+ db.close();
107
+ }
108
+ }
109
+ }
110
+ //# sourceMappingURL=Backlinks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Backlinks.js","sourceRoot":"","sources":["../../src/core/Backlinks.ts"],"names":[],"mappings":"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"}