mirror of
https://github.com/kemalai/FreeCrawl-SEO-Tool.git
synced 2026-10-18 00:39:38 +02:00
feat: in-app installer download + responsive imagery + url hygiene
This commit is contained in:
1 parent
a4daf420f0
commit
6088d808bb
18 files changed
+710
-61
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/cli",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/desktop",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"description": "FreeCrawl SEO — desktop application",
|
||||
"main": "./out/main/index.js",
|
||||
|
||||
+287
-31
@@ -6,7 +6,9 @@ import {
|
||||
dialog,
|
||||
Menu,
|
||||
Notification,
|
||||
session,
|
||||
shell,
|
||||
type DownloadItem,
|
||||
type MenuItemConstructorOptions,
|
||||
} from 'electron';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -704,6 +706,12 @@ function compareSemver(a: string, b: string): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
interface GitHubReleaseAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface GitHubLatestRelease {
|
||||
tag_name?: string;
|
||||
name?: string;
|
||||
@@ -712,21 +720,222 @@ interface GitHubLatestRelease {
|
||||
draft?: boolean;
|
||||
prerelease?: boolean;
|
||||
published_at?: string;
|
||||
assets?: GitHubReleaseAsset[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual update check. Hits the GitHub Releases API for the latest
|
||||
* non-draft, non-prerelease tag, compares it with `app.getVersion()`,
|
||||
* and surfaces a native dialog. Three outcomes:
|
||||
* Pick the installer asset for the current host platform/arch from a
|
||||
* GitHub Release's asset list. Returns null if no matching asset is
|
||||
* found — caller falls back to "open release page" so the user can
|
||||
* pick manually. Excludes electron-builder's `.blockmap` siblings; we
|
||||
* only want the user-runnable installer.
|
||||
*/
|
||||
function pickInstallerAsset(assets: GitHubReleaseAsset[]): GitHubReleaseAsset | null {
|
||||
const list = (assets ?? []).filter(
|
||||
(a) => a && typeof a.name === 'string' && !a.name.endsWith('.blockmap'),
|
||||
);
|
||||
if (process.platform === 'win32') {
|
||||
return list.find((a) => /\.exe$/i.test(a.name)) ?? null;
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
const isArm = process.arch === 'arm64';
|
||||
if (isArm) {
|
||||
return list.find((a) => /-arm64\.dmg$/i.test(a.name)) ?? null;
|
||||
}
|
||||
// Intel Macs: pick the `.dmg` that is NOT the arm64 build.
|
||||
return list.find((a) => /\.dmg$/i.test(a.name) && !/arm64/i.test(a.name)) ?? null;
|
||||
}
|
||||
// Linux installers aren't published yet; caller falls back to release page.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a subset of GitHub-flavoured Markdown so it renders cleanly in
|
||||
* Electron's plain-text `dialog.showMessageBox` `detail` field. The
|
||||
* native dialog doesn't support markdown; this turns `**bold**`,
|
||||
* `[text](url)`, headings, code fences, and bullets into readable
|
||||
* plain text instead of leaving the raw `**` / `[…](…)` punctuation
|
||||
* in the user's face. Lossy by design — full notes are one click away
|
||||
* via "Open Release Page".
|
||||
*/
|
||||
function stripMarkdownToPlain(md: string): string {
|
||||
if (!md) return '';
|
||||
return md
|
||||
// Code fences: keep the inner code lines, drop the ``` markers.
|
||||
.replace(/```[^\n]*\n([\s\S]*?)```/g, (_m, code: string) => code.trimEnd())
|
||||
// Inline code: drop the backticks.
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
// Images:  -> alt (drop URL since alt is the human-readable bit)
|
||||
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
|
||||
// Links: [text](url) -> text (url) so the URL is still visible.
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
|
||||
// Bold: **x** / __x__
|
||||
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||
.replace(/__([^_]+)__/g, '$1')
|
||||
// Italic: *x* / _x_ (single delimiter)
|
||||
.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2')
|
||||
.replace(/(^|[^_])_([^_\n]+)_(?!_)/g, '$1$2')
|
||||
// Strikethrough
|
||||
.replace(/~~([^~]+)~~/g, '$1')
|
||||
// Headings: drop the leading #'s, keep the text.
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
// Bullet list markers -> "• "
|
||||
.replace(/^\s*[-*+]\s+/gm, '• ')
|
||||
// Collapse runs of blank lines to at most one.
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pref-key namespace for "Don't show again — version X.Y.Z" — re-uses
|
||||
* the existing `skipDiag:` prefix so the Help → Reset Diagnostic
|
||||
* Warnings menu also clears these. Keying is per-version so a NEW
|
||||
* release surfaces a fresh prompt even if the user dismissed the
|
||||
* previous one.
|
||||
*/
|
||||
function dismissedUpdatePrefKey(tag: string): string {
|
||||
return `skipDiag:update-${tag}`;
|
||||
}
|
||||
|
||||
function isUpdateVersionDismissed(tag: string): boolean {
|
||||
loadPrefs();
|
||||
return prefsCache[dismissedUpdatePrefKey(tag)] === true;
|
||||
}
|
||||
|
||||
function dismissUpdateVersion(tag: string): void {
|
||||
loadPrefs();
|
||||
prefsCache[dismissedUpdatePrefKey(tag)] = true;
|
||||
schedulePrefsWrite();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an installer asset to the user's Downloads folder via
|
||||
* Electron's session download infrastructure (Chromium handles
|
||||
* redirects + connection management; we get progress + cancel events).
|
||||
* On success reveals the file in the OS file manager so the user can
|
||||
* double-click to install. Progress is surfaced through the main
|
||||
* window's taskbar/dock progress bar — no extra UI needed.
|
||||
*
|
||||
* No code-signing, so we cannot auto-execute the installer for the
|
||||
* user. Reveal-in-folder is the safest middle ground: kullanıcı one
|
||||
* click yapıyor, OS uyarısını bilinçli olarak geçiyor.
|
||||
*/
|
||||
async function downloadAndRevealInstaller(asset: GitHubReleaseAsset): Promise<void> {
|
||||
const win = mainWindow;
|
||||
if (!win) return;
|
||||
const downloadsDir = app.getPath('downloads');
|
||||
try {
|
||||
mkdirSync(downloadsDir, { recursive: true });
|
||||
} catch {
|
||||
/* getPath('downloads') always exists on Win/macOS but be defensive */
|
||||
}
|
||||
const savePath = join(downloadsDir, asset.name);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const handler = (
|
||||
_e: { preventDefault: () => void; readonly defaultPrevented: boolean },
|
||||
item: DownloadItem,
|
||||
) => {
|
||||
const url = item.getURL();
|
||||
// GitHub redirects browser_download_url → S3; both share the asset
|
||||
// filename, so we match on filename rather than exact URL.
|
||||
if (!url.includes(asset.name)) return;
|
||||
session.defaultSession.removeListener('will-download', handler);
|
||||
item.setSavePath(savePath);
|
||||
|
||||
item.on('updated', (_evt, state) => {
|
||||
if (state === 'progressing' && !item.isPaused()) {
|
||||
const total = item.getTotalBytes();
|
||||
const received = item.getReceivedBytes();
|
||||
if (total > 0) {
|
||||
try {
|
||||
win.setProgressBar(received / total);
|
||||
} catch {
|
||||
/* window may be destroyed mid-download */
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
item.once('done', (_evt, state) => {
|
||||
try {
|
||||
win.setProgressBar(-1);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (state === 'completed') {
|
||||
try {
|
||||
shell.showItemInFolder(savePath);
|
||||
} catch {
|
||||
/* ignore — user can navigate manually */
|
||||
}
|
||||
void dialog.showMessageBox(win, {
|
||||
type: 'info',
|
||||
title: 'Download Complete',
|
||||
message: `${asset.name} downloaded.`,
|
||||
detail:
|
||||
`Saved to:\n${savePath}\n\n` +
|
||||
`The Downloads folder has been opened — double-click the installer to upgrade.\n\n` +
|
||||
(process.platform === 'win32'
|
||||
? 'Windows SmartScreen may show "Unrecognized app" because the installer is not code-signed. Click "More info → Run anyway" to proceed.'
|
||||
: process.platform === 'darwin'
|
||||
? 'macOS Gatekeeper may block the app on first open because it is not notarised. Right-click the .dmg → Open to bypass.'
|
||||
: ''),
|
||||
buttons: ['OK'],
|
||||
noLink: true,
|
||||
});
|
||||
} else {
|
||||
void dialog.showMessageBox(win, {
|
||||
type: 'error',
|
||||
title: 'Download Failed',
|
||||
message: `Could not download ${asset.name}`,
|
||||
detail: `Download state: ${state}\n\nYou can retry from the GitHub Releases page.`,
|
||||
buttons: ['OK'],
|
||||
noLink: true,
|
||||
});
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
};
|
||||
session.defaultSession.on('will-download', handler);
|
||||
try {
|
||||
session.defaultSession.downloadURL(asset.browser_download_url);
|
||||
} catch (err) {
|
||||
session.defaultSession.removeListener('will-download', handler);
|
||||
void dialog.showMessageBox(win, {
|
||||
type: 'error',
|
||||
title: 'Download Failed',
|
||||
message: 'Could not start the download.',
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
buttons: ['OK'],
|
||||
noLink: true,
|
||||
});
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update check. Hits the GitHub Releases API for the latest non-draft,
|
||||
* non-prerelease tag, compares it with `app.getVersion()`, and surfaces
|
||||
* a native dialog. Three outcomes:
|
||||
* - up-to-date → "You're on the latest version (vX.Y.Z)"
|
||||
* - update available → "v0.X.Y is available" + "Open release page" / "Later"
|
||||
* - update available → "v0.X.Y is available" + Download / Open Release Page / Later
|
||||
* - network error / rate → "Couldn't check" with the underlying error
|
||||
*
|
||||
* No background polling — only runs when the user clicks the menu item.
|
||||
* `silent: true` is used by the one-shot startup auto-check — only the
|
||||
* "update available AND not dismissed by the user" outcome surfaces a
|
||||
* dialog; up-to-date / network errors / dismissed-versions stay quiet.
|
||||
* The manual menu invocation passes `silent: false` and always shows.
|
||||
*
|
||||
* No `electron-updater` dependency, no auto-install (that would need
|
||||
* macOS notarisation + Windows code-signing first).
|
||||
* macOS notarisation + Windows code-signing first). The Download
|
||||
* Installer button fetches the platform-matched asset to the user's
|
||||
* Downloads folder and reveals it in Explorer / Finder; user double-
|
||||
* clicks to upgrade.
|
||||
*/
|
||||
async function checkForUpdates(): Promise<void> {
|
||||
async function checkForUpdates(opts: { silent?: boolean } = {}): Promise<void> {
|
||||
const silent = opts.silent === true;
|
||||
const win = mainWindow;
|
||||
if (!win) return;
|
||||
const installed = app.getVersion();
|
||||
@@ -760,6 +969,14 @@ async function checkForUpdates(): Promise<void> {
|
||||
}
|
||||
|
||||
if (fetchError || !release || !release.tag_name) {
|
||||
if (silent) {
|
||||
logger.log(
|
||||
'debug',
|
||||
'main',
|
||||
`Startup update check skipped: ${fetchError ?? 'no tag in response'}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
void dialog.showMessageBox(win, {
|
||||
type: 'warning',
|
||||
title: 'Update Check Failed',
|
||||
@@ -784,6 +1001,7 @@ async function checkForUpdates(): Promise<void> {
|
||||
const latest = release.tag_name;
|
||||
const cmp = compareSemver(installed, latest);
|
||||
if (cmp >= 0) {
|
||||
if (silent) return;
|
||||
void dialog.showMessageBox(win, {
|
||||
type: 'info',
|
||||
title: 'Up to Date',
|
||||
@@ -797,37 +1015,66 @@ async function checkForUpdates(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Newer version available — show release notes preview + open page.
|
||||
// Trim release body to keep the dialog readable; full notes are on the
|
||||
// GitHub page the user opens.
|
||||
const notes = (release.body ?? '').trim();
|
||||
// User-suppressed this version via "Don't show this version again".
|
||||
// Startup auto-check honours the dismissal silently. Manual click
|
||||
// still shows so the user can re-decide / hit Download anyway.
|
||||
if (silent && isUpdateVersionDismissed(latest)) {
|
||||
logger.log('debug', 'main', `Startup update check: ${latest} dismissed by user.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Newer version available. Strip raw markdown for a readable plain-
|
||||
// text preview in the native dialog (which doesn't support markdown);
|
||||
// the dialog stays roomy for ~1500 chars now that the user can stay
|
||||
// in-app to download instead of jumping to a browser.
|
||||
const notes = stripMarkdownToPlain(release.body ?? '');
|
||||
const notesPreview =
|
||||
notes.length > 600 ? notes.slice(0, 600).trimEnd() + '\n…' : notes;
|
||||
notes.length > 1500 ? notes.slice(0, 1500).trimEnd() + '\n…' : notes;
|
||||
const installerAsset = pickInstallerAsset(release.assets ?? []);
|
||||
const detail =
|
||||
`Installed: v${installed}\nLatest: ${latest}\n\n` +
|
||||
(notesPreview
|
||||
? `Release notes:\n${notesPreview}`
|
||||
: 'See the release page for the changelog.');
|
||||
|
||||
void dialog
|
||||
.showMessageBox(win, {
|
||||
type: 'info',
|
||||
title: 'Update Available',
|
||||
message: `${latest} is available.`,
|
||||
detail,
|
||||
buttons: ['Open Release Page', 'Later'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
noLink: true,
|
||||
})
|
||||
.then((r) => {
|
||||
if (r.response === 0) {
|
||||
const url =
|
||||
release?.html_url ??
|
||||
'https://github.com/kemalai/FreeCrawl-SEO-Tool/releases/latest';
|
||||
void shell.openExternal(url);
|
||||
}
|
||||
});
|
||||
// Three-way choice: in-app download (primary, when an asset matches the
|
||||
// host platform), open release page (always available — fallback for
|
||||
// Linux / unmatched arch), Later. Plus a "Don't show this version
|
||||
// again" checkbox keyed by tag so a NEW release re-prompts.
|
||||
const buttons = installerAsset
|
||||
? ['Download Installer', 'Open Release Page', 'Later']
|
||||
: ['Open Release Page', 'Later'];
|
||||
const downloadBtn = installerAsset ? 0 : -1;
|
||||
const releasePageBtn = installerAsset ? 1 : 0;
|
||||
const laterBtn = installerAsset ? 2 : 1;
|
||||
|
||||
const result = await dialog.showMessageBox(win, {
|
||||
type: 'info',
|
||||
title: 'Update Available',
|
||||
message: `${latest} is available.`,
|
||||
detail,
|
||||
buttons,
|
||||
defaultId: downloadBtn >= 0 ? downloadBtn : releasePageBtn,
|
||||
cancelId: laterBtn,
|
||||
checkboxLabel: "Don't show this version again",
|
||||
checkboxChecked: false,
|
||||
noLink: true,
|
||||
});
|
||||
|
||||
if (result.checkboxChecked) {
|
||||
dismissUpdateVersion(latest);
|
||||
}
|
||||
|
||||
if (result.response === downloadBtn && installerAsset) {
|
||||
await downloadAndRevealInstaller(installerAsset);
|
||||
return;
|
||||
}
|
||||
if (result.response === releasePageBtn) {
|
||||
const url =
|
||||
release?.html_url ??
|
||||
'https://github.com/kemalai/FreeCrawl-SEO-Tool/releases/latest';
|
||||
void shell.openExternal(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function promptOpenProject(): Promise<void> {
|
||||
@@ -2463,6 +2710,15 @@ void app.whenReady().then(() => {
|
||||
logger.log('info', 'main', `App ready — version ${app.getVersion()}`);
|
||||
freezeWatchdog.setMainOp('idle');
|
||||
|
||||
// One-shot silent update check 8 s after launch. Ignores network /
|
||||
// up-to-date outcomes (no popup), only opens the Update Available
|
||||
// dialog when there is a newer release that hasn't been dismissed.
|
||||
// The 8 s delay lets the renderer finish first paint so the prompt
|
||||
// doesn't compete with the loading UI.
|
||||
setTimeout(() => {
|
||||
void checkForUpdates({ silent: true });
|
||||
}, 8_000);
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
|
||||
@@ -842,6 +842,10 @@ function NameValueView({ row }: { row: CrawlUrlRow }) {
|
||||
['OG Title', row.ogTitle],
|
||||
['OG Description', row.ogDescription],
|
||||
['OG Image', row.ogImage],
|
||||
['OG Type', row.ogType],
|
||||
['OG URL', row.ogUrl],
|
||||
['OG Site Name', row.ogSiteName],
|
||||
['OG Locale', row.ogLocale],
|
||||
['Twitter Card', row.twitterCard],
|
||||
['Twitter Title', row.twitterTitle],
|
||||
['Twitter Description', row.twitterDescription],
|
||||
@@ -897,6 +901,7 @@ function NameValueView({ row }: { row: CrawlUrlRow }) {
|
||||
['AMP HTML', row.amphtml],
|
||||
['Favicon', row.favicon],
|
||||
['Apple Touch Icon', row.appleTouchIcon],
|
||||
['Android / PWA Icon', row.androidIcon],
|
||||
['Web Manifest', row.manifestUrl],
|
||||
['RSS / Atom Feed', row.feedUrl],
|
||||
['Title Tag Count', row.titleCount > 1 ? row.titleCount : null],
|
||||
@@ -909,6 +914,15 @@ function NameValueView({ row }: { row: CrawlUrlRow }) {
|
||||
)}%)`
|
||||
: null,
|
||||
],
|
||||
[
|
||||
'Responsive Images',
|
||||
row.imagesCount > 0
|
||||
? `${row.imagesResponsive} / ${row.imagesCount} (${Math.round(
|
||||
(row.imagesResponsive / row.imagesCount) * 100,
|
||||
)}%)`
|
||||
: null,
|
||||
],
|
||||
['<picture> Elements', row.pictureCount > 0 ? row.pictureCount : null],
|
||||
['Form Inputs', row.formInputCount > 0 ? row.formInputCount : null],
|
||||
[
|
||||
'Form Inputs Without Label',
|
||||
|
||||
@@ -766,6 +766,12 @@ function buildTree(o: OverviewCounts | null): Node[] {
|
||||
count: o.issues.urlSpaces,
|
||||
category: 'issues:url-spaces',
|
||||
},
|
||||
{
|
||||
key: 'issues-url-malformed',
|
||||
label: 'Malformed URL',
|
||||
count: o.issues.urlMalformed,
|
||||
category: 'issues:url-malformed',
|
||||
},
|
||||
{
|
||||
key: 'issues-url-query-too-long',
|
||||
label: 'Long Query String (>100 chars)',
|
||||
@@ -1228,6 +1234,12 @@ function buildTree(o: OverviewCounts | null): Node[] {
|
||||
count: o.issues.imagesNoLazyLoading,
|
||||
category: 'issues:images-no-lazy-loading',
|
||||
},
|
||||
{
|
||||
key: 'issues-images-no-responsive',
|
||||
label: 'Low Responsive Image Adoption',
|
||||
count: o.issues.imagesNoResponsive,
|
||||
category: 'issues:images-no-responsive',
|
||||
},
|
||||
{
|
||||
key: 'issues-image-broken-src',
|
||||
label: 'Broken Image Src',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/mcp-server",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
Generated
+8
-8
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "freecrawl-seo",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "freecrawl-seo",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"apps/cli": {
|
||||
"name": "@freecrawl/cli",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"dependencies": {
|
||||
"@freecrawl/core": "*",
|
||||
"@freecrawl/db": "*",
|
||||
@@ -42,7 +42,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "@freecrawl/desktop",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"dependencies": {
|
||||
"@freecrawl/core": "*",
|
||||
"@freecrawl/db": "*",
|
||||
@@ -84,7 +84,7 @@
|
||||
},
|
||||
"apps/mcp-server": {
|
||||
"name": "@freecrawl/mcp-server",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"dependencies": {
|
||||
"@freecrawl/db": "*",
|
||||
"@freecrawl/shared-types": "*"
|
||||
@@ -8732,7 +8732,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@freecrawl/core",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"dependencies": {
|
||||
"@freecrawl/db": "*",
|
||||
"@freecrawl/shared-types": "*",
|
||||
@@ -8758,7 +8758,7 @@
|
||||
},
|
||||
"packages/db": {
|
||||
"name": "@freecrawl/db",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"dependencies": {
|
||||
"@freecrawl/shared-types": "*"
|
||||
},
|
||||
@@ -8770,7 +8770,7 @@
|
||||
},
|
||||
"packages/shared-types": {
|
||||
"name": "@freecrawl/shared-types",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"devDependencies": {
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.7.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "freecrawl-seo",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"description": "Open-source desktop SEO crawler — a free alternative to Screaming Frog",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/core",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
isSameHost,
|
||||
extractExtension,
|
||||
isInScope,
|
||||
isUrlMalformed,
|
||||
resolveStartUrl,
|
||||
compileUrlRegexRewrites,
|
||||
} from './url-utils.js';
|
||||
@@ -1131,6 +1132,7 @@ export class Crawler extends EventEmitter {
|
||||
try {
|
||||
this.db.upsertUrl({
|
||||
url: this.config.startUrl,
|
||||
urlMalformed: isUrlMalformed(this.config.startUrl) ? 1 : 0,
|
||||
contentKind: 'html',
|
||||
statusCode: null,
|
||||
statusText: 'fetching',
|
||||
@@ -1644,6 +1646,7 @@ export class Crawler extends EventEmitter {
|
||||
await this.dbCall<number>('upsertUrl', [
|
||||
{
|
||||
url: item.url,
|
||||
urlMalformed: isUrlMalformed(item.url) ? 1 : 0,
|
||||
contentKind: 'other',
|
||||
statusCode: res.status,
|
||||
statusText: 'size-cap-exceeded',
|
||||
@@ -1813,6 +1816,7 @@ export class Crawler extends EventEmitter {
|
||||
const redirectUrlId = await this.dbCall<number>('upsertUrl', [
|
||||
{
|
||||
url: item.url,
|
||||
urlMalformed: isUrlMalformed(item.url) ? 1 : 0,
|
||||
contentKind: kind,
|
||||
statusCode,
|
||||
statusText: null,
|
||||
@@ -1889,6 +1893,7 @@ export class Crawler extends EventEmitter {
|
||||
const nonHtmlUrlId = await this.dbCall<number>('upsertUrl', [
|
||||
{
|
||||
url: item.url,
|
||||
urlMalformed: isUrlMalformed(item.url) ? 1 : 0,
|
||||
contentKind: kind,
|
||||
statusCode,
|
||||
statusText: null,
|
||||
@@ -1997,6 +2002,7 @@ export class Crawler extends EventEmitter {
|
||||
const { urlId } = await this.writeFetchedUrl({
|
||||
upsert: {
|
||||
url: item.url,
|
||||
urlMalformed: isUrlMalformed(item.url) ? 1 : 0,
|
||||
contentKind: 'html',
|
||||
statusCode,
|
||||
statusText: null,
|
||||
@@ -2030,6 +2036,10 @@ export class Crawler extends EventEmitter {
|
||||
ogTitle: parsed.ogTitle,
|
||||
ogDescription: parsed.ogDescription,
|
||||
ogImage: parsed.ogImage,
|
||||
ogType: parsed.ogType,
|
||||
ogUrl: parsed.ogUrl,
|
||||
ogSiteName: parsed.ogSiteName,
|
||||
ogLocale: parsed.ogLocale,
|
||||
twitterCard: parsed.twitterCard,
|
||||
twitterTitle: parsed.twitterTitle,
|
||||
twitterDescription: parsed.twitterDescription,
|
||||
@@ -2059,6 +2069,7 @@ export class Crawler extends EventEmitter {
|
||||
amphtml: parsed.amphtml,
|
||||
favicon: parsed.favicon,
|
||||
appleTouchIcon: parsed.appleTouchIcon,
|
||||
androidIcon: parsed.androidIcon,
|
||||
manifestUrl: parsed.manifestUrl,
|
||||
feedUrl: parsed.feedUrl,
|
||||
mixedContentCount: parsed.mixedContentCount,
|
||||
@@ -2096,6 +2107,8 @@ export class Crawler extends EventEmitter {
|
||||
formInputCount: parsed.formInputCount,
|
||||
formInputUnlabeled: parsed.formInputUnlabeledCount,
|
||||
imagesLazy: parsed.imagesLazy,
|
||||
imagesResponsive: parsed.imagesResponsive,
|
||||
pictureCount: parsed.pictureCount,
|
||||
headings:
|
||||
parsed.headings.length > 0 ? JSON.stringify(parsed.headings) : null,
|
||||
serverHeader,
|
||||
@@ -2223,6 +2236,7 @@ export class Crawler extends EventEmitter {
|
||||
await this.dbCall<number>('upsertUrl', [
|
||||
{
|
||||
url: item.url,
|
||||
urlMalformed: isUrlMalformed(item.url) ? 1 : 0,
|
||||
contentKind: 'html',
|
||||
statusCode: null,
|
||||
statusText: detail,
|
||||
|
||||
@@ -52,6 +52,14 @@ export interface ParsedPage {
|
||||
ogTitle: string | null;
|
||||
ogDescription: string | null;
|
||||
ogImage: string | null;
|
||||
/** `og:type` value (e.g. `website`, `article`, `product`), lowercased. */
|
||||
ogType: string | null;
|
||||
/** Raw `og:url` attribute value — verbatim, not resolved. */
|
||||
ogUrl: string | null;
|
||||
/** `og:site_name` — entity-decoded human-readable site label. */
|
||||
ogSiteName: string | null;
|
||||
/** `og:locale` (e.g. `en_US`, `tr_TR`). */
|
||||
ogLocale: string | null;
|
||||
twitterCard: string | null;
|
||||
twitterTitle: string | null;
|
||||
twitterDescription: string | null;
|
||||
@@ -100,6 +108,13 @@ export interface ParsedPage {
|
||||
favicon: string | null;
|
||||
/** Resolved `<link rel="apple-touch-icon">` URL, else null. */
|
||||
appleTouchIcon: string | null;
|
||||
/**
|
||||
* First `<link rel="icon" sizes>` whose declared dimension is ≥192px
|
||||
* (or `sizes="any"` vector). Android Chrome / PWA launchers use a
|
||||
* 192px asset, so this is the right adoption signal for "is there a
|
||||
* home-screen icon" — distinct from the smaller favicon.
|
||||
*/
|
||||
androidIcon: string | null;
|
||||
/** Resolved `<link rel="manifest">` URL, else null. */
|
||||
manifestUrl: string | null;
|
||||
/** Resolved RSS / Atom feed `<link rel="alternate">` URL, else null. */
|
||||
@@ -142,6 +157,16 @@ export interface ParsedPage {
|
||||
* adopted on image-heavy pages — a common LCP / CLS optimisation lever.
|
||||
*/
|
||||
imagesLazy: number;
|
||||
/**
|
||||
* Number of `<img>` slots that participate in responsive imagery —
|
||||
* either the `<img>` itself carries a `srcset` attribute, or it sits
|
||||
* inside a `<picture>` element whose `<source>` children supply the
|
||||
* variants. Counted per `<img>`, not per candidate URL, so the value
|
||||
* is comparable to `imagesCount` for adoption ratios.
|
||||
*/
|
||||
imagesResponsive: number;
|
||||
/** Number of `<picture>` elements on the page. */
|
||||
pictureCount: number;
|
||||
/**
|
||||
* Total user-facing form inputs on the page (`<input>`, `<textarea>`,
|
||||
* `<select>`), excluding `type=hidden / submit / button / image / reset`
|
||||
@@ -297,6 +322,19 @@ export function parseHtml(
|
||||
($('meta[property="og:description"]').attr('content') ?? '').trim(),
|
||||
) || null;
|
||||
const ogImage = ($('meta[property="og:image"]').attr('content') ?? '').trim() || null;
|
||||
// Remaining OpenGraph metas. `og:url` we leave as the raw content for
|
||||
// downstream comparison with the canonical (the spec says it should
|
||||
// be absolute, but many sites ship a relative value — surfacing it
|
||||
// verbatim is more useful than silently resolving).
|
||||
const ogType =
|
||||
($('meta[property="og:type"]').attr('content') ?? '').trim().toLowerCase() ||
|
||||
null;
|
||||
const ogUrl = ($('meta[property="og:url"]').attr('content') ?? '').trim() || null;
|
||||
const ogSiteName =
|
||||
decodeEntities(($('meta[property="og:site_name"]').attr('content') ?? '').trim()) ||
|
||||
null;
|
||||
const ogLocale =
|
||||
($('meta[property="og:locale"]').attr('content') ?? '').trim() || null;
|
||||
|
||||
// Twitter Cards use `name=` (not `property=`) per Twitter's spec. Many
|
||||
// sites leave one set missing and rely on the other — we capture both.
|
||||
@@ -462,6 +500,36 @@ export function parseHtml(
|
||||
? normalizeUrl(appleTouchRaw, pageUrl, opts.urlRewrites)
|
||||
: null;
|
||||
|
||||
// Android Chrome / PWA icon — `<link rel="icon" sizes="192x192">` (or
|
||||
// larger). The home-screen launcher uses 192px on Android, so this is
|
||||
// the right adoption signal distinct from the small favicon. We pick
|
||||
// the first `<link rel="icon">` whose declared `sizes` includes a
|
||||
// dimension >=192 in either axis. `sizes="any"` (vector icons) also
|
||||
// qualifies — the launcher rasterises them at 192.
|
||||
let androidIcon: string | null = null;
|
||||
$('link[rel="icon"][sizes]').each((_, el) => {
|
||||
if (androidIcon) return;
|
||||
const sizes = ($(el).attr('sizes') ?? '').trim().toLowerCase();
|
||||
if (!sizes) return;
|
||||
const isVector = sizes === 'any';
|
||||
let qualifies = isVector;
|
||||
if (!qualifies) {
|
||||
// `sizes` may carry multiple space-separated WxH tokens.
|
||||
for (const token of sizes.split(/\s+/)) {
|
||||
const m = token.match(/^(\d+)x(\d+)$/);
|
||||
if (m && (Number(m[1]) >= 192 || Number(m[2]) >= 192)) {
|
||||
qualifies = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!qualifies) return;
|
||||
const href = ($(el).attr('href') ?? '').trim();
|
||||
if (!href) return;
|
||||
const resolved = normalizeUrl(href, pageUrl, opts.urlRewrites);
|
||||
if (resolved) androidIcon = resolved;
|
||||
});
|
||||
|
||||
// Web app manifest — PWA support signal.
|
||||
const manifestRaw = ($('link[rel="manifest"]').first().attr('href') ?? '').trim();
|
||||
const manifestUrl = manifestRaw
|
||||
@@ -768,9 +836,63 @@ export function parseHtml(
|
||||
|
||||
let imagesEmptyAlt = 0;
|
||||
let imagesLazy = 0;
|
||||
let imagesResponsive = 0;
|
||||
const imageMap = new Map<string, DiscoveredImage>();
|
||||
|
||||
// Helper: parse a `srcset` attribute value into the list of candidate
|
||||
// URLs. The HTML spec allows `URL [whitespace] [Nw|Nx]` entries
|
||||
// separated by commas, but commas can also appear inside URLs (rare —
|
||||
// typically encoded as %2C, but we can't assume). The robust split is
|
||||
// by whitespace-or-EOS-after-descriptor, but a comma+whitespace split
|
||||
// catches the overwhelming majority of real-world srcsets and is what
|
||||
// the WHATWG reference parser uses for the simple form.
|
||||
const parseSrcset = (raw: string): string[] => {
|
||||
const out: string[] = [];
|
||||
for (const entry of raw.split(/,(?=\s)/)) {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) continue;
|
||||
// The URL is everything up to the first whitespace; the rest is
|
||||
// the optional descriptor (e.g. `1x`, `2x`, `768w`).
|
||||
const m = trimmed.match(/^(\S+)/);
|
||||
if (m && m[1]) out.push(m[1]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// Add a candidate URL discovered from a srcset/picture-source. Same
|
||||
// rules as the `<img src>` loop — skip data URIs, normalise, dedupe.
|
||||
// The parent `<img>` provides the alt/width/height fallback when we
|
||||
// see a srcset variant for the first time.
|
||||
const addImageCandidate = (
|
||||
rawUrl: string,
|
||||
alt: string | null,
|
||||
width: number | null,
|
||||
height: number | null,
|
||||
): void => {
|
||||
if (!rawUrl) return;
|
||||
if (rawUrl.startsWith('data:')) return;
|
||||
const normalized = normalizeUrl(rawUrl, pageUrl, opts.urlRewrites);
|
||||
if (!normalized) return;
|
||||
if (!/^https?:/.test(normalized)) return;
|
||||
if (imageMap.has(normalized)) return;
|
||||
imageMap.set(normalized, {
|
||||
src: normalized,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
isInternal: isSameHost(pageUrl, normalized, opts),
|
||||
});
|
||||
};
|
||||
|
||||
// Count `<picture>` elements — each <picture> wraps one <img> plus
|
||||
// multiple <source> alternates (different formats / breakpoints).
|
||||
// The bare count surfaces in the URL Details panel as a "responsive
|
||||
// imagery adoption" signal alongside `images_responsive`.
|
||||
const pictureCount = $('picture').length;
|
||||
|
||||
$('img[src]').each((_, el) => {
|
||||
const rawSrc = $(el).attr('src');
|
||||
const $el = $(el);
|
||||
const rawSrc = $el.attr('src');
|
||||
if (!rawSrc) return;
|
||||
// Skip inline data URIs — they're not "web resources" in the crawler
|
||||
// sense and would bloat the images table fast on any CMS.
|
||||
@@ -778,9 +900,8 @@ export function parseHtml(
|
||||
const normalized = normalizeUrl(rawSrc, pageUrl, opts.urlRewrites);
|
||||
if (!normalized) return;
|
||||
if (!/^https?:/.test(normalized)) return;
|
||||
if (imageMap.has(normalized)) return;
|
||||
|
||||
const altAttr = $(el).attr('alt');
|
||||
const altAttr = $el.attr('alt');
|
||||
const alt =
|
||||
altAttr === undefined
|
||||
? null // alt missing entirely (accessibility issue)
|
||||
@@ -792,23 +913,55 @@ export function parseHtml(
|
||||
// image_usages rows whose alt is null.
|
||||
if (altAttr.trim() === '') imagesEmptyAlt++;
|
||||
}
|
||||
const width = parseIntAttr($(el).attr('width'));
|
||||
const height = parseIntAttr($(el).attr('height'));
|
||||
const width = parseIntAttr($el.attr('width'));
|
||||
const height = parseIntAttr($el.attr('height'));
|
||||
const isInternal = isSameHost(pageUrl, normalized, opts);
|
||||
|
||||
// Lazy-loading adoption — `loading="lazy"` is the native browser
|
||||
// attribute and the only signal that's reliable without rendering JS.
|
||||
if (($(el).attr('loading') ?? '').trim().toLowerCase() === 'lazy') {
|
||||
if (($el.attr('loading') ?? '').trim().toLowerCase() === 'lazy') {
|
||||
imagesLazy++;
|
||||
}
|
||||
|
||||
imageMap.set(normalized, {
|
||||
src: normalized,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
isInternal,
|
||||
});
|
||||
// Responsive imagery — an `<img>` is "responsive" if it carries
|
||||
// `srcset` itself OR is wrapped in `<picture>` (the parent supplies
|
||||
// the variants via `<source>` siblings). We only count the primary
|
||||
// `<img>` here so a single page contributes 1 per slot, not 1 per
|
||||
// variant URL — the variant URLs themselves go into `imageMap` so
|
||||
// the broken-image / large-image probes still see them.
|
||||
const srcsetAttr = ($el.attr('srcset') ?? '').trim();
|
||||
const inPicture = $el.parent('picture').length > 0;
|
||||
if (srcsetAttr || inPicture) imagesResponsive++;
|
||||
|
||||
if (!imageMap.has(normalized)) {
|
||||
imageMap.set(normalized, {
|
||||
src: normalized,
|
||||
alt,
|
||||
width,
|
||||
height,
|
||||
isInternal,
|
||||
});
|
||||
}
|
||||
|
||||
// Capture every candidate URL from the `<img srcset>` so the same
|
||||
// pages that trigger Broken-Image / Large-Image probes pick them up.
|
||||
if (srcsetAttr) {
|
||||
for (const candidate of parseSrcset(srcsetAttr)) {
|
||||
addImageCandidate(candidate, alt, null, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// `<picture><source srcset>` — the alternates the browser may pick
|
||||
// instead of the inner `<img src>`. Includes format-fallback cases
|
||||
// (avif → webp → jpg) where the primary `<img>` references only the
|
||||
// last fallback.
|
||||
$('picture > source[srcset]').each((_, el) => {
|
||||
const srcsetAttr = ($(el).attr('srcset') ?? '').trim();
|
||||
if (!srcsetAttr) return;
|
||||
for (const candidate of parseSrcset(srcsetAttr)) {
|
||||
addImageCandidate(candidate, null, null, null);
|
||||
}
|
||||
});
|
||||
|
||||
// OG / Twitter share-card images. They rarely appear inside `<img>`
|
||||
@@ -852,6 +1005,10 @@ export function parseHtml(
|
||||
ogTitle,
|
||||
ogDescription,
|
||||
ogImage,
|
||||
ogType,
|
||||
ogUrl,
|
||||
ogSiteName,
|
||||
ogLocale,
|
||||
twitterCard,
|
||||
twitterTitle,
|
||||
twitterDescription,
|
||||
@@ -874,6 +1031,7 @@ export function parseHtml(
|
||||
amphtml,
|
||||
favicon,
|
||||
appleTouchIcon,
|
||||
androidIcon,
|
||||
manifestUrl,
|
||||
feedUrl,
|
||||
emptyAnchorCount,
|
||||
@@ -886,6 +1044,8 @@ export function parseHtml(
|
||||
complexWordCount: readability.complexWordCount,
|
||||
imagesEmptyAlt,
|
||||
imagesLazy,
|
||||
imagesResponsive,
|
||||
pictureCount,
|
||||
formInputCount,
|
||||
formInputUnlabeledCount,
|
||||
headings,
|
||||
|
||||
@@ -218,6 +218,44 @@ export function extractExtension(url: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect URLs that browsers tolerate but search engines, log analysers,
|
||||
* and CDN edge rules may handle inconsistently. The WHATWG URL parser
|
||||
* (which `normalizeUrl` runs each href through) accepts a wide grammar
|
||||
* that includes structural ambiguities — we surface those as a separate
|
||||
* "Malformed URL" issue without rejecting the URL outright.
|
||||
*
|
||||
* Returns true when the URL has any of:
|
||||
* - Multiple `?` characters (query string with literal `?` in a value
|
||||
* — legal per RFC 3986 but widely mis-parsed by reverse proxies).
|
||||
* - Multiple `#` characters (fragments are stripped on normalize, so
|
||||
* this only triggers on URLs whose path/query contains a literal
|
||||
* `%23` decoded back into `#`).
|
||||
* - ASCII control characters (codepoints < 0x20 or 0x7F).
|
||||
* - Unescaped reserved characters that should be percent-encoded:
|
||||
* `<`, `>`, `"`, backtick, `{`, `}`, `|`, `\`, `^`.
|
||||
* - Double-encoding sequences (`%25` followed by two hex digits — a
|
||||
* percent-sign that was already itself percent-encoded, suggesting
|
||||
* a CMS double-encoded an already-encoded URL).
|
||||
*/
|
||||
export function isUrlMalformed(url: string): boolean {
|
||||
if (!url) return false;
|
||||
// Multiple `?` — split on first `?` and look for another in the remainder.
|
||||
const firstQ = url.indexOf('?');
|
||||
if (firstQ !== -1 && url.indexOf('?', firstQ + 1) !== -1) return true;
|
||||
// Multiple `#` — same idea.
|
||||
const firstH = url.indexOf('#');
|
||||
if (firstH !== -1 && url.indexOf('#', firstH + 1) !== -1) return true;
|
||||
// Control chars + unescaped reserved chars.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\x00-\x1F\x7F<>"`{}|\\^]/.test(url)) return true;
|
||||
// Double-encoding pattern: `%25` followed by two hex digits is a
|
||||
// percent-sign whose own `%` was percent-encoded — almost always a
|
||||
// double-encoded URL bug (e.g. `%2520` = encoded `%20` = encoded space).
|
||||
if (/%25[0-9A-Fa-f]{2}/.test(url)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user-typed start URL to a full URL with protocol, following
|
||||
* any initial redirect chain so the crawler's scope calculation (e.g.
|
||||
|
||||
@@ -70,6 +70,10 @@ const XML_COLUMNS: (keyof CrawlUrlRow)[] = [
|
||||
'ogTitle',
|
||||
'ogDescription',
|
||||
'ogImage',
|
||||
'ogType',
|
||||
'ogUrl',
|
||||
'ogSiteName',
|
||||
'ogLocale',
|
||||
'twitterCard',
|
||||
'twitterTitle',
|
||||
'twitterDescription',
|
||||
@@ -94,6 +98,7 @@ const XML_COLUMNS: (keyof CrawlUrlRow)[] = [
|
||||
'amphtml',
|
||||
'favicon',
|
||||
'appleTouchIcon',
|
||||
'androidIcon',
|
||||
'manifestUrl',
|
||||
'feedUrl',
|
||||
'metaRefresh',
|
||||
@@ -106,6 +111,8 @@ const XML_COLUMNS: (keyof CrawlUrlRow)[] = [
|
||||
'imagesMissingAlt',
|
||||
'imagesEmptyAlt',
|
||||
'imagesLazy',
|
||||
'imagesResponsive',
|
||||
'pictureCount',
|
||||
'jsOnlyLinksCount',
|
||||
'textCodeRatio',
|
||||
'fleschReadingEase',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/db",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -1024,6 +1024,64 @@ const MIGRATIONS: Migration[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 49,
|
||||
name: 'add_responsive_imagery_columns',
|
||||
// Track responsive-imagery adoption per page so the "Responsive
|
||||
// Images Missing" issue filter can surface image-heavy pages that
|
||||
// ship a single `<img src>` (no `srcset`, no `<picture>`) — a
|
||||
// missed mobile-bandwidth optimisation lever and a common LCP
|
||||
// regression on retina screens.
|
||||
// - `images_responsive`: count of `<img>` slots that carry
|
||||
// `srcset` themselves OR sit inside a `<picture>` parent.
|
||||
// - `picture_count`: count of `<picture>` elements; useful
|
||||
// on its own as an art-direction adoption signal.
|
||||
up: (db) => {
|
||||
const cols = db.prepare('PRAGMA table_info(urls)').all() as unknown as {
|
||||
name: string;
|
||||
}[];
|
||||
const has = (n: string) => cols.some((c) => c.name === n);
|
||||
if (!has('images_responsive'))
|
||||
db.exec(
|
||||
'ALTER TABLE urls ADD COLUMN images_responsive INTEGER NOT NULL DEFAULT 0',
|
||||
);
|
||||
if (!has('picture_count'))
|
||||
db.exec('ALTER TABLE urls ADD COLUMN picture_count INTEGER NOT NULL DEFAULT 0');
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 50,
|
||||
name: 'add_malformed_og_extras_android_icon',
|
||||
// V1 Faz 1 polish bundle (6 columns, single migration):
|
||||
// - `url_malformed` 0/1 — set when the canonical URL string
|
||||
// contains structural issues that browsers tolerate but search
|
||||
// engines / log analysers may diverge on (multiple `?`, multiple
|
||||
// `#`, control chars, unescaped reserved chars, double-encoding
|
||||
// sequences). Surfaces as the "Malformed URL" issue filter.
|
||||
// - `og_type`/`og_url`/`og_site_name`/`og_locale` — the four
|
||||
// remaining OpenGraph meta tags we weren't yet capturing.
|
||||
// Useful for share-card validation and locale-aware crawls.
|
||||
// - `android_icon` — first `<link rel="icon">` with sizes ≥
|
||||
// 192x192, the resolution Android home-screen uses for PWA
|
||||
// icons. Distinct from the smaller favicon.
|
||||
up: (db) => {
|
||||
const cols = db.prepare('PRAGMA table_info(urls)').all() as unknown as {
|
||||
name: string;
|
||||
}[];
|
||||
const has = (n: string) => cols.some((c) => c.name === n);
|
||||
if (!has('url_malformed'))
|
||||
db.exec(
|
||||
'ALTER TABLE urls ADD COLUMN url_malformed INTEGER NOT NULL DEFAULT 0',
|
||||
);
|
||||
if (!has('og_type')) db.exec('ALTER TABLE urls ADD COLUMN og_type TEXT');
|
||||
if (!has('og_url')) db.exec('ALTER TABLE urls ADD COLUMN og_url TEXT');
|
||||
if (!has('og_site_name'))
|
||||
db.exec('ALTER TABLE urls ADD COLUMN og_site_name TEXT');
|
||||
if (!has('og_locale')) db.exec('ALTER TABLE urls ADD COLUMN og_locale TEXT');
|
||||
if (!has('android_icon'))
|
||||
db.exec('ALTER TABLE urls ADD COLUMN android_icon TEXT');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function runMigrations(db: DatabaseSync): void {
|
||||
|
||||
@@ -145,6 +145,14 @@ interface UrlRowDb {
|
||||
cors_allow_credentials: number;
|
||||
cors_allow_methods: string | null;
|
||||
cors_allow_headers: string | null;
|
||||
images_responsive: number;
|
||||
picture_count: number;
|
||||
url_malformed: number;
|
||||
og_type: string | null;
|
||||
og_url: string | null;
|
||||
og_site_name: string | null;
|
||||
og_locale: string | null;
|
||||
android_icon: string | null;
|
||||
}
|
||||
|
||||
interface ImageRowDb {
|
||||
@@ -257,6 +265,15 @@ export interface UpsertUrlInput {
|
||||
formInputCount?: number;
|
||||
formInputUnlabeled?: number;
|
||||
imagesLazy?: number;
|
||||
imagesResponsive?: number;
|
||||
pictureCount?: number;
|
||||
/** 0/1 — set by the crawler via `isUrlMalformed(url)` from `@freecrawl/core/url-utils`. */
|
||||
urlMalformed?: number;
|
||||
ogType?: string | null;
|
||||
ogUrl?: string | null;
|
||||
ogSiteName?: string | null;
|
||||
ogLocale?: string | null;
|
||||
androidIcon?: string | null;
|
||||
/** JSON-stringified outline array, or null. */
|
||||
headings?: string | null;
|
||||
/** Raw `Server` response header, or null when absent. */
|
||||
@@ -319,7 +336,9 @@ const UPSERT_URL_SQL = `
|
||||
server_header,
|
||||
js_only_links_count, text_code_ratio,
|
||||
flesch_reading_ease, flesch_kincaid_grade, gunning_fog_index, sentence_count, complex_word_count,
|
||||
cors_allow_origin, cors_allow_credentials, cors_allow_methods, cors_allow_headers
|
||||
cors_allow_origin, cors_allow_credentials, cors_allow_methods, cors_allow_headers,
|
||||
images_responsive, picture_count,
|
||||
url_malformed, og_type, og_url, og_site_name, og_locale, android_icon
|
||||
) VALUES (
|
||||
:url, :content_kind, :status_code, :status_text, :indexability, :indexability_reason,
|
||||
:title, :title_length, :meta_description, :meta_description_length,
|
||||
@@ -353,7 +372,9 @@ const UPSERT_URL_SQL = `
|
||||
:server_header,
|
||||
:js_only_links_count, :text_code_ratio,
|
||||
:flesch_reading_ease, :flesch_kincaid_grade, :gunning_fog_index, :sentence_count, :complex_word_count,
|
||||
:cors_allow_origin, :cors_allow_credentials, :cors_allow_methods, :cors_allow_headers
|
||||
:cors_allow_origin, :cors_allow_credentials, :cors_allow_methods, :cors_allow_headers,
|
||||
:images_responsive, :picture_count,
|
||||
:url_malformed, :og_type, :og_url, :og_site_name, :og_locale, :android_icon
|
||||
)
|
||||
ON CONFLICT(url) DO UPDATE SET
|
||||
content_kind = excluded.content_kind,
|
||||
@@ -466,6 +487,14 @@ const UPSERT_URL_SQL = `
|
||||
cors_allow_credentials = excluded.cors_allow_credentials,
|
||||
cors_allow_methods = excluded.cors_allow_methods,
|
||||
cors_allow_headers = excluded.cors_allow_headers,
|
||||
images_responsive = excluded.images_responsive,
|
||||
picture_count = excluded.picture_count,
|
||||
url_malformed = excluded.url_malformed,
|
||||
og_type = excluded.og_type,
|
||||
og_url = excluded.og_url,
|
||||
og_site_name = excluded.og_site_name,
|
||||
og_locale = excluded.og_locale,
|
||||
android_icon = excluded.android_icon,
|
||||
crawled_at = CURRENT_TIMESTAMP
|
||||
RETURNING id
|
||||
`;
|
||||
@@ -1093,6 +1122,14 @@ export class ProjectDb {
|
||||
cors_allow_credentials: input.corsAllowCredentials ?? -1,
|
||||
cors_allow_methods: input.corsAllowMethods ?? null,
|
||||
cors_allow_headers: input.corsAllowHeaders ?? null,
|
||||
images_responsive: input.imagesResponsive ?? 0,
|
||||
picture_count: input.pictureCount ?? 0,
|
||||
url_malformed: input.urlMalformed ?? 0,
|
||||
og_type: input.ogType ?? null,
|
||||
og_url: input.ogUrl ?? null,
|
||||
og_site_name: input.ogSiteName ?? null,
|
||||
og_locale: input.ogLocale ?? null,
|
||||
android_icon: input.androidIcon ?? null,
|
||||
};
|
||||
|
||||
const row = this.stmtUpsertUrl.get(params) as { id: number } | undefined;
|
||||
@@ -2752,6 +2789,7 @@ export class ProjectDb {
|
||||
urlSpaces: countWhere(
|
||||
"is_external = 0 AND (INSTR(url, ' ') > 0 OR INSTR(url, '%20') > 0)",
|
||||
),
|
||||
urlMalformed: countWhere('is_external = 0 AND url_malformed = 1'),
|
||||
imageEmptyAlt: countWhere(`${html} AND images_empty_alt > 0`),
|
||||
linkEmptyAnchor: countWhere(`${html} AND empty_anchor_count > 0`),
|
||||
appleTouchIconMissing: countWhere(
|
||||
@@ -2925,6 +2963,9 @@ export class ProjectDb {
|
||||
imagesNoLazyLoading: countWhere(
|
||||
`${html} AND images_count >= 5 AND (images_lazy * 2) < images_count`,
|
||||
),
|
||||
imagesNoResponsive: countWhere(
|
||||
`${html} AND images_count >= 5 AND (images_responsive * 2) < images_count`,
|
||||
),
|
||||
imageBrokenSrc: countWhere(
|
||||
`${html} AND EXISTS (
|
||||
SELECT 1 FROM image_usages iu
|
||||
@@ -4363,6 +4404,14 @@ export class ProjectDb {
|
||||
corsAllowCredentials: r.cors_allow_credentials ?? -1,
|
||||
corsAllowMethods: r.cors_allow_methods ?? null,
|
||||
corsAllowHeaders: r.cors_allow_headers ?? null,
|
||||
imagesResponsive: r.images_responsive ?? 0,
|
||||
pictureCount: r.picture_count ?? 0,
|
||||
urlMalformed: r.url_malformed ?? 0,
|
||||
ogType: r.og_type ?? null,
|
||||
ogUrl: r.og_url ?? null,
|
||||
ogSiteName: r.og_site_name ?? null,
|
||||
ogLocale: r.og_locale ?? null,
|
||||
androidIcon: r.android_icon ?? null,
|
||||
crawledAt: r.crawled_at,
|
||||
});
|
||||
|
||||
@@ -5075,6 +5124,12 @@ function categoryWhereClause(cat: UrlCategory): string | null {
|
||||
// Literal space or `%20` in URL — encoding bug, often from CMS that
|
||||
// produced filenames with spaces.
|
||||
return "is_external = 0 AND (INSTR(url, ' ') > 0 OR INSTR(url, '%20') > 0)";
|
||||
case 'issues:url-malformed':
|
||||
// Computed by `isUrlMalformed` at upsert time. Captures the
|
||||
// ambiguities the URL parser tolerates but middleware diverges on:
|
||||
// multiple `?`/`#`, control chars, unescaped reserved chars,
|
||||
// double-encoding sequences (`%2520` etc).
|
||||
return 'is_external = 0 AND url_malformed = 1';
|
||||
case 'issues:image-empty-alt':
|
||||
// alt="" specifically. Decorative images use this intentionally, but
|
||||
// many sites apply it to content images by mistake.
|
||||
@@ -5354,6 +5409,14 @@ function categoryWhereClause(cat: UrlCategory): string | null {
|
||||
return `is_external = 0 AND content_kind = 'html'
|
||||
AND images_count >= 5
|
||||
AND (images_lazy * 2) < images_count`;
|
||||
case 'issues:images-no-responsive':
|
||||
// Pages with ≥5 images but `srcset`/`<picture>` adoption < 50%.
|
||||
// Same 5-image floor as the lazy-loading rule — single-image hero
|
||||
// pages legitimately ship a fixed-size art-directed image without
|
||||
// a responsive variant set.
|
||||
return `is_external = 0 AND content_kind = 'html'
|
||||
AND images_count >= 5
|
||||
AND (images_responsive * 2) < images_count`;
|
||||
case 'issues:image-broken-src':
|
||||
// Pages referencing at least one internal image whose HEAD probe
|
||||
// returned a 4xx/5xx status. Uses the existing `probe_status`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@freecrawl/shared-types",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -117,6 +117,7 @@ export type UrlCategory =
|
||||
| 'issues:title-multiple'
|
||||
| 'issues:url-fragment'
|
||||
| 'issues:url-spaces'
|
||||
| 'issues:url-malformed'
|
||||
| 'issues:link-empty-anchor'
|
||||
| 'issues:apple-touch-icon-missing'
|
||||
| 'issues:manifest-missing'
|
||||
@@ -152,6 +153,7 @@ export type UrlCategory =
|
||||
| 'issues:anchor-text-generic'
|
||||
| 'issues:form-input-unlabeled'
|
||||
| 'issues:images-no-lazy-loading'
|
||||
| 'issues:images-no-responsive'
|
||||
| 'issues:image-broken-src'
|
||||
| 'issues:target-blank-no-noopener'
|
||||
| 'issues:page-empty'
|
||||
@@ -245,6 +247,27 @@ export interface CrawlUrlRow {
|
||||
imagesEmptyAlt: number;
|
||||
/** Number of `<img>` tags with `loading="lazy"`. */
|
||||
imagesLazy: number;
|
||||
/**
|
||||
* Number of `<img>` slots that participate in responsive imagery —
|
||||
* either via a `srcset` attribute on the `<img>` itself or via a
|
||||
* `<picture>` parent with `<source>` siblings. Counted per `<img>`,
|
||||
* comparable to `imagesCount` for adoption ratios.
|
||||
*/
|
||||
imagesResponsive: number;
|
||||
/** Number of `<picture>` elements on the page. */
|
||||
pictureCount: number;
|
||||
/** 0/1 — set when the canonical URL string is structurally suspect (multiple `?`/`#`, control chars, unescaped reserved chars, double-encoding). */
|
||||
urlMalformed: number;
|
||||
/** `og:type` (e.g. `website`, `article`, `product`), lowercased. */
|
||||
ogType: string | null;
|
||||
/** Raw `og:url` content, verbatim. */
|
||||
ogUrl: string | null;
|
||||
/** `og:site_name`. */
|
||||
ogSiteName: string | null;
|
||||
/** `og:locale` (e.g. `en_US`). */
|
||||
ogLocale: string | null;
|
||||
/** First `<link rel="icon" sizes>` ≥ 192px (or `sizes="any"`). PWA / Android home-screen icon. */
|
||||
androidIcon: string | null;
|
||||
/** Total user-facing form inputs (input/textarea/select, excluding hidden/submit/button/image/reset). */
|
||||
formInputCount: number;
|
||||
/** Form inputs without label / aria-label / title (WCAG 1.3.1, 4.1.2 violation). */
|
||||
@@ -946,6 +969,8 @@ export interface OverviewCounts {
|
||||
titleMultiple: number;
|
||||
urlFragment: number;
|
||||
urlSpaces: number;
|
||||
/** URLs flagged by `isUrlMalformed` (multiple `?`/`#`, control chars, unescaped reserved chars, double-encoding). */
|
||||
urlMalformed: number;
|
||||
imageEmptyAlt: number;
|
||||
linkEmptyAnchor: number;
|
||||
appleTouchIconMissing: number;
|
||||
@@ -998,6 +1023,8 @@ export interface OverviewCounts {
|
||||
formInputUnlabeled: number;
|
||||
/** Pages with ≥5 images but lazy-loading adoption below 50%. */
|
||||
imagesNoLazyLoading: number;
|
||||
/** Pages with ≥5 images but `srcset`/`<picture>` adoption below 50%. */
|
||||
imagesNoResponsive: number;
|
||||
/** Pages referencing at least one image whose HEAD probe returned a 4xx/5xx status. */
|
||||
imageBrokenSrc: number;
|
||||
/** Pages with at least one `<a target="_blank">` without `rel="noopener"` (reverse-tabnabbing risk). */
|
||||
|
||||
Reference in new issue
Block a user