feat: in-app installer download + responsive imagery + url hygiene

This commit is contained in:
kemalai committed 2026-05-04 03:01:21 +03:00
1 parent a4daf420f0
commit 6088d808bb
18 files changed
+710 -61

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/cli",
"version": "0.3.1",
"version": "0.3.2",
"private": true,
"type": "module",
"bin": {
+1 -1
View File
@@ -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
View File
@@ -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](url) -> 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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/mcp-server",
"version": "0.3.1",
"version": "0.3.2",
"private": true,
"type": "module",
"bin": {
+8 -8
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/core",
"version": "0.3.1",
"version": "0.3.2",
"private": true,
"type": "module",
"main": "./dist/index.js",
+14
View File
@@ -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,
+173 -13
View File
@@ -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,
+38
View File
@@ -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.
+7
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/db",
"version": "0.3.1",
"version": "0.3.2",
"private": true,
"type": "module",
"main": "./dist/index.js",
+58
View File
@@ -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 {
+65 -2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@freecrawl/shared-types",
"version": "0.3.1",
"version": "0.3.2",
"private": true,
"type": "module",
"main": "./dist/index.js",
+27
View File
@@ -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). */