From ff1330868e3259cfee1fa43ca47d0b9657dcfadd Mon Sep 17 00:00:00 2001 From: root Date: Thu, 11 Sep 2025 07:31:02 +0000 Subject: [PATCH 01/16] Add Github Workflow --- .github/workflows/docker-build-push.yaml | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/docker-build-push.yaml diff --git a/.github/workflows/docker-build-push.yaml b/.github/workflows/docker-build-push.yaml new file mode 100644 index 0000000..2e5b200 --- /dev/null +++ b/.github/workflows/docker-build-push.yaml @@ -0,0 +1,27 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - main + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout the repository + uses: actions/checkout@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v4 + with: + push: true + tags: ${{ secrets.DOCKER_USERNAME }}/playwright-domain-scanner:latest + From fb133dff6c5b226e20e6e139678e85b1714ac9f8 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 11 Sep 2025 07:31:31 +0000 Subject: [PATCH 02/16] Add Github Workflow --- .forgejo/workflows/docker-build-push.yaml | 42 ----------------------- 1 file changed, 42 deletions(-) delete mode 100644 .forgejo/workflows/docker-build-push.yaml diff --git a/.forgejo/workflows/docker-build-push.yaml b/.forgejo/workflows/docker-build-push.yaml deleted file mode 100644 index 340cceb..0000000 --- a/.forgejo/workflows/docker-build-push.yaml +++ /dev/null @@ -1,42 +0,0 @@ -name: Build and Push Docker Image - -on: - push: - branches: - - main - paths: - - '**/*' # Триггер при любом изменении репозитория - -jobs: - build-and-push: - runs-on: docker - - container: - image: docker:24.0.1 - - steps: - # Установка Docker CLI (если не в базовом образе) - - name: Setup Docker CLI - run: | - apk add --no-cache docker-cli - - # Авторизация в Docker Hub - токен необходимо добавить в Secrets - - name: Login to Docker Hub - env: - DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} - run: | - echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin - - # Клонирование исходников — встроено в Forgejo Actions - - # Сборка Docker образа - - name: Build Docker Image - run: | - docker build -t ${DOCKER_USERNAME}/playwright-domain-scanner:latest . - - # Push образ на Docker Hub - - name: Push Docker Image - run: | - docker push ${DOCKER_USERNAME}/playwright-domain-scanner:latest - From e1911bf5e8c075ccce305e3dd8c546d09908dd51 Mon Sep 17 00:00:00 2001 From: g00dvin Date: Thu, 11 Sep 2025 07:45:35 +0000 Subject: [PATCH 03/16] Fix dockerhub image name --- .github/workflows/docker-build-push.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-build-push.yaml b/.github/workflows/docker-build-push.yaml index 2e5b200..3933b65 100644 --- a/.github/workflows/docker-build-push.yaml +++ b/.github/workflows/docker-build-push.yaml @@ -23,5 +23,5 @@ jobs: uses: docker/build-push-action@v4 with: push: true - tags: ${{ secrets.DOCKER_USERNAME }}/playwright-domain-scanner:latest + tags: ${{ secrets.DOCKER_USERNAME }}/gekata:latest From 7c2a58c894d47bb700b047473a8effb2c218b919 Mon Sep 17 00:00:00 2001 From: g00dvin Date: Thu, 11 Sep 2025 07:57:09 +0000 Subject: [PATCH 04/16] Add env setting to workflow --- .github/workflows/docker-build-push.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docker-build-push.yaml b/.github/workflows/docker-build-push.yaml index 3933b65..be6ac82 100644 --- a/.github/workflows/docker-build-push.yaml +++ b/.github/workflows/docker-build-push.yaml @@ -8,6 +8,7 @@ on: jobs: build-and-push: runs-on: ubuntu-latest + environment: dockerhub steps: - name: Checkout the repository From 46b32d6aa7c656cefc55cfbc3a3603bc4a9b8167 Mon Sep 17 00:00:00 2001 From: g00dvin Date: Thu, 11 Sep 2025 08:08:43 +0000 Subject: [PATCH 05/16] Change base image for OCI --- Dockerfile | 36 ++++-------------------------------- 1 file changed, 4 insertions(+), 32 deletions(-) diff --git a/Dockerfile b/Dockerfile index f95e58b..94123d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,46 +1,18 @@ -# Use official Node.js LTS base image -FROM node:20-slim +FROM mcr.microsoft.com/playwright:v1.42.0-jammy -# Install dependencies for running Chromium -RUN apt-get update && apt-get install -y \ - ca-certificates \ - fonts-liberation \ - libasound2 \ - libatk-bridge2.0-0 \ - libatk1.0-0 \ - libcups2 \ - libdbus-1-3 \ - libdrm2 \ - libgbm1 \ - libgtk-3-0 \ - libnspr4 \ - libnss3 \ - libx11-xcb1 \ - libxcomposite1 \ - libxdamage1 \ - libxrandr2 \ - xdg-utils \ - wget \ - --no-install-recommends && \ - rm -rf /var/lib/apt/lists/* - -# Set working directory WORKDIR /usr/src/app -# Copy package files and install dependencies COPY package*.json ./ COPY ignore-domains.txt ./ -RUN npm ci -# Install Playwright browsers (Chromium) +RUN npm ci --omit=dev RUN npx playwright install chromium -# Copy app sources COPY . . -# Expose port +RUN rm -rf /usr/local/share/doc /usr/local/share/man /usr/local/share/info + EXPOSE 3000 -# Run the service CMD ["npm", "start"] From cdd36afd3353769d49415d4d2ba970db6fc07b9c Mon Sep 17 00:00:00 2001 From: g00dvin Date: Thu, 11 Sep 2025 09:23:02 +0000 Subject: [PATCH 06/16] Change base image for OSI to trixie:slim --- Dockerfile | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index 94123d5..29b2c59 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,25 @@ -FROM mcr.microsoft.com/playwright:v1.42.0-jammy +FROM node:20-trixie-slim +ENV DEBIAN_FRONTEND=noninteractive WORKDIR /usr/src/app +# Базовые утилиты, без лишних рекоммендованных пакетов +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl gnupg && \ + rm -rf /var/lib/apt/lists/* + COPY package*.json ./ +RUN npm ci --omit=dev + +# Ставим только headless shell Chromium и его системные зависимости +RUN npx playwright install --with-deps --only-shell && \ + rm -rf /usr/share/doc /usr/share/man /var/cache/apt/* + +# Копируем минимально нужные исходники +COPY server.js ./ +# Если используется игнор-лист как файл — раскомментируйте строку: COPY ignore-domains.txt ./ -RUN npm ci --omit=dev -RUN npx playwright install chromium - -COPY . . - -RUN rm -rf /usr/local/share/doc /usr/local/share/man /usr/local/share/info - EXPOSE 3000 - -CMD ["npm", "start"] +CMD ["node", "server.js"] From 09f054b8884ae41d0d08ed4a1052d9107d522063 Mon Sep 17 00:00:00 2001 From: g00dvin Date: Thu, 11 Sep 2025 09:59:28 +0000 Subject: [PATCH 07/16] Use headless chromium --- Dockerfile | 47 +++++++++++++++++++++++++++++++++++------------ server.js | 37 ++++++++++++++++++++++++++++++------- 2 files changed, 65 insertions(+), 19 deletions(-) diff --git a/Dockerfile b/Dockerfile index 29b2c59..adf380a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,48 @@ -FROM node:20-trixie-slim +# Base minimal Debian +FROM debian:bookworm-slim +# Prevent tzdata prompts ENV DEBIAN_FRONTEND=noninteractive -WORKDIR /usr/src/app -# Базовые утилиты, без лишних рекоммендованных пакетов +# Install Node.js, Chromium and minimal runtime libs +# Note: chromium package on Debian provides /usr/bin/chromium RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates curl gnupg && \ - rm -rf /var/lib/apt/lists/* + ca-certificates curl gnupg \ + nodejs npm \ + chromium \ + # Minimal GUI/Chromium runtime libs often needed by Playwright Chromium + libx11-6 libxcomposite1 libxdamage1 libxrandr2 libxkbcommon0 \ + libgtk-3-0 libnss3 libdrm2 libgbm1 libasound2 fonts-liberation \ + # Useful for font rendering + fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* +# App directory +WORKDIR /app + +# Install only production deps COPY package*.json ./ +ENV CI=true RUN npm ci --omit=dev -# Ставим только headless shell Chromium и его системные зависимости -RUN npx playwright install --with-deps --only-shell && \ - rm -rf /usr/share/doc /usr/share/man /var/cache/apt/* +# Copy source +COPY . . -# Копируем минимально нужные исходники -COPY server.js ./ -# Если используется игнор-лист как файл — раскомментируйте строку: -COPY ignore-domains.txt ./ +# Security: run as non-root +RUN useradd -ms /bin/bash nodeuser && chown -R nodeuser:nodeuser /app +USER nodeuser +# Environment for service +ENV PORT=3000 \ + # Ensure Playwright uses system Chromium and does not download browsers + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \ + PLAYWRIGHT_BROWSERS_PATH=0 \ + # Explicit executable if needed in code; here server uses default, so optional + CHROMIUM_PATH=/usr/bin/chromium + +# Expose service port EXPOSE 3000 + +# Start the service CMD ["node", "server.js"] diff --git a/server.js b/server.js index f6ef7fe..dde4a67 100644 --- a/server.js +++ b/server.js @@ -1,9 +1,22 @@ +// server.js const express = require('express'); const { chromium } = require('playwright'); const app = express(); const port = process.env.PORT || 3000; +// Использовать системный Chromium, если задан путь (например, /usr/bin/chromium в Debian) +const executablePath = process.env.CHROMIUM_PATH || undefined; // можно оставить undefined, если Chromium в PATH [1][2] + +// Базовый набор флагов для контейнера без systemd/dbus и без install-deps +const chromiumArgs = [ + '--no-sandbox', // запуск без setuid sandbox в контейнере [14] + '--disable-setuid-sandbox', // отключение setuid sandbox [14] + '--disable-dev-shm-usage', // использовать /tmp вместо /dev/shm (если нет --ipc=host) [15][16] + '--disable-gpu', // headless окружение [14] + '--no-zygote', // упрощение процессов в контейнере [14] +]; + app.use(express.json()); function extractDomain(url) { @@ -23,12 +36,17 @@ app.get('/domains', async (req, res) => { const url = `https://${domain}`; const seenDomains = new Set(); + let browser; + let context; try { - const browser = await chromium.launch({ - args: ['--no-sandbox', '--disable-setuid-sandbox'] + browser = await chromium.launch({ + executablePath, // берётся из CHROMIUM_PATH при наличии [1][2] + headless: true, // явный headless режим для контейнера [14] + args: chromiumArgs, // флаги для стабильности в Docker [15][14] }); - const context = await browser.newContext(); + + context = await browser.newContext(); const page = await context.newPage(); page.on('request', request => { @@ -37,15 +55,20 @@ app.get('/domains', async (req, res) => { }); await page.goto(url, { waitUntil: 'load', timeout: 30000 }); + + // Фильтрация доменов после закрытия страницы + await context.close(); await browser.close(); - // Фильтрация доменов - const filteredDomains = Array.from(seenDomains).filter(d => - !d.includes('doubleclick') && !d.includes('google') - ).sort(); + const filteredDomains = Array.from(seenDomains) + .filter(d => !d.includes('doubleclick') && !d.includes('google')) + .sort(); res.json({ domains: filteredDomains }); } catch (e) { + // Безопасно закрыть ресурсы при ошибке + try { if (context) await context.close(); } catch {} + try { if (browser) await browser.close(); } catch {} res.status(500).json({ error: e.message || 'Internal server error' }); } }); From 361fa4fafe8a6ef78b92ffa1c3fdb8fd7ab96f4b Mon Sep 17 00:00:00 2001 From: g00dvin Date: Fri, 12 Sep 2025 09:13:47 +0000 Subject: [PATCH 08/16] Add 301 logic and cache --- server.js | 246 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 200 insertions(+), 46 deletions(-) diff --git a/server.js b/server.js index dde4a67..0a2706f 100644 --- a/server.js +++ b/server.js @@ -1,30 +1,183 @@ // server.js const express = require('express'); const { chromium } = require('playwright'); +const Database = require('better-sqlite3'); const app = express(); const port = process.env.PORT || 3000; -// Использовать системный Chromium, если задан путь (например, /usr/bin/chromium в Debian) -const executablePath = process.env.CHROMIUM_PATH || undefined; // можно оставить undefined, если Chromium в PATH [1][2] - -// Базовый набор флагов для контейнера без systemd/dbus и без install-deps +const executablePath = process.env.CHROMIUM_PATH || undefined; const chromiumArgs = [ - '--no-sandbox', // запуск без setuid sandbox в контейнере [14] - '--disable-setuid-sandbox', // отключение setuid sandbox [14] - '--disable-dev-shm-usage', // использовать /tmp вместо /dev/shm (если нет --ipc=host) [15][16] - '--disable-gpu', // headless окружение [14] - '--no-zygote', // упрощение процессов в контейнере [14] + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + '--no-zygote', ]; +const CACHE_TTL_SECONDS = parseInt(process.env.CACHE_TTL_SECONDS || '21600', 10); +const MAX_REDIRECT_STEPS = parseInt(process.env.MAX_REDIRECT_STEPS || '20', 10); // анти-цикл по глубине + +const db = new Database(process.env.SQLITE_PATH || './cache.db'); +db.pragma('journal_mode = WAL'); +db.exec(` +CREATE TABLE IF NOT EXISTS domain_cache ( + domain TEXT PRIMARY KEY, + result_json TEXT NOT NULL, -- JSON массива связанных доменов + final_url TEXT, + redirect_chain_json TEXT, -- JSON журнала редиректов + updated_at INTEGER NOT NULL, + ttl_at INTEGER NOT NULL +); +`); + +const stmtSelect = db.prepare(` + SELECT result_json, final_url, redirect_chain_json, updated_at, ttl_at + FROM domain_cache WHERE domain = ? +`); +const stmtUpsert = db.prepare(` +INSERT INTO domain_cache (domain, result_json, final_url, redirect_chain_json, updated_at, ttl_at) +VALUES (@domain, @result_json, @final_url, @redirect_chain_json, @updated_at, @ttl_at) +ON CONFLICT(domain) DO UPDATE SET + result_json = excluded.result_json, + final_url = excluded.final_url, + redirect_chain_json = excluded.redirect_chain_json, + updated_at = excluded.updated_at, + ttl_at = excluded.ttl_at +`); + app.use(express.json()); function extractDomain(url) { - try { - return new URL(url).hostname; - } catch { - return null; + try { return new URL(url).hostname; } catch { return null; } +} + +let browser; +async function getBrowser() { + if (browser && browser.isConnected()) return browser; + browser = await chromium.launch({ + executablePath, + headless: true, + args: chromiumArgs, + }); + return browser; +} + +// Вспомогательная функция для сборки полного журнала редиректов через цепочку redirectedFrom() +function buildRedirectChainForResponse(resp) { + const chain = []; + const currentReq = resp.request(); + let prev = currentReq.redirectedFrom(); + let toUrl = currentReq.url(); + const status = resp.status(); + while (prev) { + chain.push({ from: prev.url(), to: toUrl, status }); + toUrl = prev.url(); + prev = prev.redirectedFrom(); } + return chain.reverse(); +} + +async function scanDomainOnce(originDomain) { + const startUrl = `https://${originDomain}`; + + const b = await getBrowser(); + const context = await b.newContext(); + const page = await context.newPage(); + + const seenDomains = new Set(); + const redirectLog = []; + const visitedUrls = new Set(); // для детекции циклов + let redirectSteps = 0; + + // Фиксируем все запросы + page.on('request', req => { + const d = extractDomain(req.url()); + if (d) seenDomains.add(d); + }); + + // Фиксируем ответы и редиректные цепочки + page.on('response', resp => { + const url = resp.url(); + const d = extractDomain(url); + if (d) seenDomains.add(d); + + // Добавим элементы цепочки, если ответ был редиректом (3xx) + const status = resp.status(); + if (status >= 300 && status < 400) { + const piece = buildRedirectChainForResponse(resp); + redirectLog.push(...piece); + } + }); + + try { + let currentUrl = startUrl; + // Анти-цикл: свой контроль над goto в несколько шагов — через ожидание события navigation и проверку URL + // Однако Playwright следует редиректам сам; для анти-цикла контролируем уникальность URL после перехода + const resp = await page.goto(currentUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); + // После авто-редиректов Playwright мы проверим фактическую цепочку через обработчики и page.url() + + // Защита от «вечных» редиректов: проверим историю URL в performance entries + // Простой и надёжный способ: считать шаги смены URL в waitForNavigation с url predicate — но нам достаточно лимита по постфакту. + // Проверим финальный URL и убедимся, что не было явного зацикливания по уже виденным URL. + const finalUrl = page.url(); + if (visitedUrls.has(finalUrl)) { + throw new Error('Redirect loop detected'); + } + visitedUrls.add(finalUrl); + + // Как дополнительная защита — лимит по шагам 3xx из собранного redirectLog + // Если цепочка слишком длинная, считаем её небезопасной. + redirectSteps = redirectLog.length; + if (redirectSteps > MAX_REDIRECT_STEPS) { + throw new Error(`Too many redirects (${redirectSteps})`); + } + + await context.close(); + + const relatedDomains = Array.from(seenDomains) + .filter(d => !d.includes('doubleclick') && !d.includes('google')) + .sort(); + + return { + finalUrl, + relatedDomains, + redirectChain: redirectLog, + }; + } catch (e) { + try { await context.close(); } catch {} + throw e; + } +} + +function getFromCache(domain) { + const row = stmtSelect.get(domain); + if (!row) return null; + const now = Math.floor(Date.now() / 1000); + if (row.ttl_at > now) { + return { + relatedDomains: JSON.parse(row.result_json), + finalUrl: row.final_url || null, + redirectChain: row.redirect_chain_json ? JSON.parse(row.redirect_chain_json) : [], + cached: true, + cachedAt: row.updated_at, + ttlAt: row.ttl_at, + }; + } + return null; +} + +function putToCache(domain, result) { + const now = Math.floor(Date.now() / 1000); + const ttlAt = now + CACHE_TTL_SECONDS; + stmtUpsert.run({ + domain, + result_json: JSON.stringify(result.relatedDomains || []), + final_url: result.finalUrl || null, + redirect_chain_json: JSON.stringify(result.redirectChain || []), + updated_at: now, + ttl_at: ttlAt, + }); } app.get('/domains', async (req, res) => { @@ -33,46 +186,47 @@ app.get('/domains', async (req, res) => { res.status(400).json({ error: '"domain" query parameter is required' }); return; } - - const url = `https://${domain}`; - const seenDomains = new Set(); - let browser; - let context; - try { - browser = await chromium.launch({ - executablePath, // берётся из CHROMIUM_PATH при наличии [1][2] - headless: true, // явный headless режим для контейнера [14] - args: chromiumArgs, // флаги для стабильности в Docker [15][14] + const cached = getFromCache(domain); + if (cached) { + res.json({ + domain, + finalUrl: cached.finalUrl, + relatedDomains: cached.relatedDomains, + redirectChain: cached.redirectChain, + cached: true, + cachedAt: cached.cachedAt, + ttlAt: cached.ttlAt, + }); + return; + } + + const result = await scanDomainOnce(domain); + putToCache(domain, result); + + res.json({ + domain, + finalUrl: result.finalUrl, + relatedDomains: result.relatedDomains, + redirectChain: result.redirectChain, + cached: false, }); - - context = await browser.newContext(); - const page = await context.newPage(); - - page.on('request', request => { - const d = extractDomain(request.url()); - if (d) seenDomains.add(d); - }); - - await page.goto(url, { waitUntil: 'load', timeout: 30000 }); - - // Фильтрация доменов после закрытия страницы - await context.close(); - await browser.close(); - - const filteredDomains = Array.from(seenDomains) - .filter(d => !d.includes('doubleclick') && !d.includes('google')) - .sort(); - - res.json({ domains: filteredDomains }); } catch (e) { - // Безопасно закрыть ресурсы при ошибке - try { if (context) await context.close(); } catch {} - try { if (browser) await browser.close(); } catch {} res.status(500).json({ error: e.message || 'Internal server error' }); } }); +app.get('/health', (_req, res) => res.json({ ok: true })); + +process.on('SIGTERM', async () => { + try { if (browser) await browser.close(); } catch {} + process.exit(0); +}); +process.on('SIGINT', async () => { + try { if (browser) await browser.close(); } catch {} + process.exit(0); +}); + app.listen(port, () => { console.log(`Domain scanner service listening on port ${port}`); }); From a5a0ed828d41dc77e6d827ae531c620779902fc7 Mon Sep 17 00:00:00 2001 From: g00dvin Date: Fri, 12 Sep 2025 09:39:06 +0000 Subject: [PATCH 09/16] Add sqlite dep --- package-lock.json | 387 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 5 +- 2 files changed, 390 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2c45af6..6a98bf3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "playwright-domain-scanner", "version": "1.0.0", "dependencies": { + "better-sqlite3": "^9.0.0", "express": "^4.18.2", "playwright": "^1.42.0" } @@ -29,6 +30,53 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/better-sqlite3": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==", + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -52,6 +100,29 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -87,6 +158,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -127,6 +203,28 @@ "ms": "2.0.0" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -144,6 +242,14 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -170,6 +276,14 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -210,6 +324,14 @@ "node": ">= 0.6" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "engines": { + "node": ">=6" + } + }, "node_modules/express": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", @@ -255,6 +377,11 @@ "url": "https://opencollective.com/express" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, "node_modules/finalhandler": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", @@ -288,6 +415,11 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -344,6 +476,11 @@ "node": ">= 0.4" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -403,11 +540,35 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -478,11 +639,40 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -491,6 +681,17 @@ "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.77.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.77.0.tgz", + "integrity": "sha512-DSmt0OEcLoK4i3NuscSbGjOf3bqiDEutejqENSplMSFA/gmB8mkED9G4pKWnPl7MDU4rSHebKPHeitpDfyH0cQ==", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -513,6 +714,14 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -554,6 +763,31 @@ "node": ">=18" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -566,6 +800,15 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -602,6 +845,33 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -626,6 +896,17 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "0.19.0", "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", @@ -749,6 +1030,49 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -757,6 +1081,48 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", + "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -765,6 +1131,17 @@ "node": ">=0.6" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -785,6 +1162,11 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -800,6 +1182,11 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" } } } diff --git a/package.json b/package.json index da44098..c6a44ac 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "playwright-domain-scanner", + "name": "gekata", "version": "1.0.0", "description": "Service to find all domains loaded by webpage using Playwright", "main": "server.js", @@ -8,7 +8,8 @@ }, "dependencies": { "express": "^4.18.2", - "playwright": "^1.42.0" + "playwright": "^1.42.0", + "better-sqlite3": "^9.0.0" } } From 72752b1a0b9df675b7971e39cf916d6591ece8bc Mon Sep 17 00:00:00 2001 From: g00dvin Date: Fri, 12 Sep 2025 16:21:33 +0000 Subject: [PATCH 10/16] Add tini as INIT in container. Rewrite server.js --- Dockerfile | 57 +++++--- server.js | 372 +++++++++++++++++++++++++++++++++++------------------ 2 files changed, 282 insertions(+), 147 deletions(-) diff --git a/Dockerfile b/Dockerfile index adf380a..628bd48 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,48 +1,65 @@ -# Base minimal Debian -FROM debian:bookworm-slim - -# Prevent tzdata prompts +# -------- Builder stage -------- +FROM debian:bookworm-slim AS builder ENV DEBIAN_FRONTEND=noninteractive -# Install Node.js, Chromium and minimal runtime libs -# Note: chromium package on Debian provides /usr/bin/chromium +# Node + build tools for native modules (better-sqlite3) RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl gnupg \ nodejs npm \ - chromium \ - # Minimal GUI/Chromium runtime libs often needed by Playwright Chromium - libx11-6 libxcomposite1 libxdamage1 libxrandr2 libxkbcommon0 \ - libgtk-3-0 libnss3 libdrm2 libgbm1 libasound2 fonts-liberation \ - # Useful for font rendering - fonts-dejavu-core \ + python3 make g++ pkg-config libsqlite3-dev \ && rm -rf /var/lib/apt/lists/* -# App directory WORKDIR /app -# Install only production deps +# Copy only manifests first to leverage Docker cache COPY package*.json ./ + +# Install production deps (build native modules here) ENV CI=true RUN npm ci --omit=dev # Copy source COPY . . -# Security: run as non-root +# -------- Runtime stage -------- +FROM debian:bookworm-slim +ENV DEBIAN_FRONTEND=noninteractive + +# Install tini for proper PID 1 and signal handling +# Install Node.js runtime, Chromium and minimal libs +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl gnupg \ + tini \ + nodejs npm \ + chromium \ + libx11-6 libxcomposite1 libxdamage1 libxrandr2 libxkbcommon0 \ + libgtk-3-0 libnss3 libdrm2 libgbm1 libasound2 fonts-liberation \ + fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy node_modules and app from builder +COPY --from=builder /app/node_modules /app/node_modules +COPY --from=builder /app/package*.json /app/ +COPY . . + +# Security: drop root RUN useradd -ms /bin/bash nodeuser && chown -R nodeuser:nodeuser /app USER nodeuser -# Environment for service +# Environment ENV PORT=3000 \ - # Ensure Playwright uses system Chromium and does not download browsers PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \ PLAYWRIGHT_BROWSERS_PATH=0 \ - # Explicit executable if needed in code; here server uses default, so optional - CHROMIUM_PATH=/usr/bin/chromium + CHROMIUM_PATH=/usr/bin/chromium \ + CACHE_TTL_SECONDS=21600 -# Expose service port EXPOSE 3000 +# Use tini as PID 1 so we don't need `--init` +ENTRYPOINT ["/usr/bin/tini", "--"] + # Start the service CMD ["node", "server.js"] diff --git a/server.js b/server.js index 0a2706f..e5332ca 100644 --- a/server.js +++ b/server.js @@ -1,36 +1,111 @@ -// server.js +// server.js (hardened) const express = require('express'); +const rateLimit = require('express-rate-limit'); const { chromium } = require('playwright'); const Database = require('better-sqlite3'); +const punycode = require('punycode/'); -const app = express(); -const port = process.env.PORT || 3000; +// ---------- Config ---------- +const PORT = Number(process.env.PORT || 3000); +const CHROMIUM_PATH = process.env.CHROMIUM_PATH || undefined; +const CACHE_TTL_SECONDS = parseInt(process.env.CACHE_TTL_SECONDS || '21600', 10); +const MAX_REDIRECT_STEPS = parseInt(process.env.MAX_REDIRECT_STEPS || '20', 10); +const CONCURRENCY = parseInt(process.env.CONCURRENCY || '3', 10); +const SQLITE_PATH = process.env.SQLITE_PATH || './cache.db'; +const MAX_DOMAINS = parseInt(process.env.MAX_DOMAINS || '5000', 10); +const MAX_REDIRECT_LOG = parseInt(process.env.MAX_REDIRECT_LOG || '50', 10); +const NAV_TIMEOUT_MS = parseInt(process.env.NAV_TIMEOUT_MS || '30000', 10); +const QUIET_WINDOW_MS = parseInt(process.env.QUIET_WINDOW_MS || '600', 10); // «маленькая тишина» -const executablePath = process.env.CHROMIUM_PATH || undefined; -const chromiumArgs = [ +const CHROMIUM_ARGS = [ '--no-sandbox', '--disable-setuid-sandbox', - '--disable-dev-shm-usage', + '--disable-dev-shm-usage', // рекомендуется заменить на --ipc=host при запуске контейнера '--disable-gpu', '--no-zygote', ]; -const CACHE_TTL_SECONDS = parseInt(process.env.CACHE_TTL_SECONDS || '21600', 10); -const MAX_REDIRECT_STEPS = parseInt(process.env.MAX_REDIRECT_STEPS || '20', 10); // анти-цикл по глубине +// ---------- Helpers ---------- +const app = express(); +app.use(express.json()); -const db = new Database(process.env.SQLITE_PATH || './cache.db'); +// Basic rate limit (per-IP) +const limiter = rateLimit({ + windowMs: 60_000, + max: 30, + standardHeaders: true, + legacyHeaders: false, +}); +app.use(limiter); + +// Normalize/validate domain +function normalizeDomain(input) { + if (!input || typeof input !== 'string') return null; + const s = input.trim().toLowerCase(); + // запрет схем/путей — ожидается чистый host + try { + // Если пришёл URL, извлечь hostname + const u = new URL(/^https?:\/\//i.test(s) ? s : `https://${s}`); + const host = u.hostname; + // IDNA -> ASCII + const ascii = punycode.toASCII(host); + if (!ascii || ascii.length > 253) return null; + return ascii; + } catch { + // Попытка интерпретации как host напрямую + try { + const ascii = punycode.toASCII(s); + return ascii || null; + } catch { + return null; + } + } +} + +function extractDomain(url) { + try { return new URL(url).hostname.toLowerCase(); } catch { return null; } +} + +// ---------- Simple semaphore ---------- +class Semaphore { + constructor(limit) { + this.limit = limit; + this.active = 0; + this.queue = []; + } + acquire() { + return new Promise(resolve => { + const tryAcquire = () => { + if (this.active < this.limit) { + this.active++; + resolve(() => { + this.active--; + const next = this.queue.shift(); + if (next) next(); + }); + } else { + this.queue.push(tryAcquire); + } + }; + tryAcquire(); + }); + } +} +const sem = new Semaphore(CONCURRENCY); + +// ---------- DB ---------- +const db = new Database(SQLITE_PATH); db.pragma('journal_mode = WAL'); db.exec(` CREATE TABLE IF NOT EXISTS domain_cache ( domain TEXT PRIMARY KEY, - result_json TEXT NOT NULL, -- JSON массива связанных доменов + result_json TEXT NOT NULL, final_url TEXT, - redirect_chain_json TEXT, -- JSON журнала редиректов + redirect_chain_json TEXT, updated_at INTEGER NOT NULL, ttl_at INTEGER NOT NULL ); `); - const stmtSelect = db.prepare(` SELECT result_json, final_url, redirect_chain_json, updated_at, ttl_at FROM domain_cache WHERE domain = ? @@ -46,110 +121,6 @@ ON CONFLICT(domain) DO UPDATE SET ttl_at = excluded.ttl_at `); -app.use(express.json()); - -function extractDomain(url) { - try { return new URL(url).hostname; } catch { return null; } -} - -let browser; -async function getBrowser() { - if (browser && browser.isConnected()) return browser; - browser = await chromium.launch({ - executablePath, - headless: true, - args: chromiumArgs, - }); - return browser; -} - -// Вспомогательная функция для сборки полного журнала редиректов через цепочку redirectedFrom() -function buildRedirectChainForResponse(resp) { - const chain = []; - const currentReq = resp.request(); - let prev = currentReq.redirectedFrom(); - let toUrl = currentReq.url(); - const status = resp.status(); - while (prev) { - chain.push({ from: prev.url(), to: toUrl, status }); - toUrl = prev.url(); - prev = prev.redirectedFrom(); - } - return chain.reverse(); -} - -async function scanDomainOnce(originDomain) { - const startUrl = `https://${originDomain}`; - - const b = await getBrowser(); - const context = await b.newContext(); - const page = await context.newPage(); - - const seenDomains = new Set(); - const redirectLog = []; - const visitedUrls = new Set(); // для детекции циклов - let redirectSteps = 0; - - // Фиксируем все запросы - page.on('request', req => { - const d = extractDomain(req.url()); - if (d) seenDomains.add(d); - }); - - // Фиксируем ответы и редиректные цепочки - page.on('response', resp => { - const url = resp.url(); - const d = extractDomain(url); - if (d) seenDomains.add(d); - - // Добавим элементы цепочки, если ответ был редиректом (3xx) - const status = resp.status(); - if (status >= 300 && status < 400) { - const piece = buildRedirectChainForResponse(resp); - redirectLog.push(...piece); - } - }); - - try { - let currentUrl = startUrl; - // Анти-цикл: свой контроль над goto в несколько шагов — через ожидание события navigation и проверку URL - // Однако Playwright следует редиректам сам; для анти-цикла контролируем уникальность URL после перехода - const resp = await page.goto(currentUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); - // После авто-редиректов Playwright мы проверим фактическую цепочку через обработчики и page.url() - - // Защита от «вечных» редиректов: проверим историю URL в performance entries - // Простой и надёжный способ: считать шаги смены URL в waitForNavigation с url predicate — но нам достаточно лимита по постфакту. - // Проверим финальный URL и убедимся, что не было явного зацикливания по уже виденным URL. - const finalUrl = page.url(); - if (visitedUrls.has(finalUrl)) { - throw new Error('Redirect loop detected'); - } - visitedUrls.add(finalUrl); - - // Как дополнительная защита — лимит по шагам 3xx из собранного redirectLog - // Если цепочка слишком длинная, считаем её небезопасной. - redirectSteps = redirectLog.length; - if (redirectSteps > MAX_REDIRECT_STEPS) { - throw new Error(`Too many redirects (${redirectSteps})`); - } - - await context.close(); - - const relatedDomains = Array.from(seenDomains) - .filter(d => !d.includes('doubleclick') && !d.includes('google')) - .sort(); - - return { - finalUrl, - relatedDomains, - redirectChain: redirectLog, - }; - } catch (e) { - try { await context.close(); } catch {} - throw e; - } -} - function getFromCache(domain) { const row = stmtSelect.get(domain); if (!row) return null; @@ -166,7 +137,6 @@ function getFromCache(domain) { } return null; } - function putToCache(domain, result) { const now = Math.floor(Date.now() / 1000); const ttlAt = now + CACHE_TTL_SECONDS; @@ -180,17 +150,160 @@ function putToCache(domain, result) { }); } +// ---------- Browser lifecycle ---------- +let browser; +async function ensureBrowser() { + try { + if (browser && browser.isConnected()) return browser; + } catch {} + if (browser) { + try { await browser.close(); } catch {} + } + browser = await chromium.launch({ + executablePath: CHROMIUM_PATH, + headless: true, + args: CHROMIUM_ARGS, + }); + return browser; +} + +// ---------- Redirect utilities ---------- +function buildRedirectChainForResponse(resp) { + const chain = []; + const currentReq = resp.request(); + let prev = currentReq.redirectedFrom(); + let toUrl = currentReq.url(); + const status = resp.status(); + while (prev) { + chain.push({ from: prev.url(), to: toUrl, status }); + toUrl = prev.url(); + prev = prev.redirectedFrom(); + if (chain.length >= MAX_REDIRECT_LOG) break; + } + return chain.reverse(); +} + +// ---------- Core scan ---------- +async function scanDomainOnce(originDomain, signal) { + const startUrl = `https://${originDomain}`; + const b = await ensureBrowser(); + const context = await b.newContext(); + const page = await context.newPage(); + + const seenDomains = new Set(); + const redirectLog = []; + const visitedUrls = new Set(); + const seenPairs = new Set(); // from|to для детекции петель + + // Бюджеты + let droppedDomains = 0; + + // Capture network + // Lightweight counter для «тихого» окна + let inflight = 0; + let lastNetChange = Date.now(); + + const onReq = req => { + inflight++; + lastNetChange = Date.now(); + const d = extractDomain(req.url()); + if (d) { + if (seenDomains.size < MAX_DOMAINS) seenDomains.add(d); + else droppedDomains++; + } + }; + const onResp = resp => { + inflight = Math.max(0, inflight - 1); + lastNetChange = Date.now(); + const url = resp.url(); + const d = extractDomain(url); + if (d) { + if (seenDomains.size < MAX_DOMAINS) seenDomains.add(d); + else droppedDomains++; + } + const status = resp.status(); + if (status >= 300 && status < 400) { + const piece = buildRedirectChainForResponse(resp); + for (const p of piece) { + if (redirectLog.length >= MAX_REDIRECT_LOG) break; + const key = `${p.from}|${p.to}`; + if (!seenPairs.has(key)) { + seenPairs.add(key); + redirectLog.push(p); + } else { + // петля + // ничего не делаем здесь — оценим ниже общим правилом + } + } + } + }; + + page.on('request', onReq); + page.on('response', onResp); + + try { + // Навигация: domcontentloaded, затем дождаться короткой «тишины» + await page.goto(startUrl, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS }); + + // Простейшее ожидание «тишины» сети, но с общим таймаутом + const startWait = Date.now(); + while (Date.now() - startWait < NAV_TIMEOUT_MS) { + if (signal?.aborted) throw new Error('Aborted'); + const quietFor = Date.now() - lastNetChange; + if (inflight === 0 && quietFor >= QUIET_WINDOW_MS) break; + await new Promise(r => setTimeout(r, 100)); + } + + const finalUrl = page.url(); + // Анти-цикл: повтор URL или превышение лимита шагов/пар + if (visitedUrls.has(finalUrl)) throw new Error('Redirect loop detected'); + visitedUrls.add(finalUrl); + + const steps = redirectLog.length; + if (steps > MAX_REDIRECT_STEPS) throw new Error(`Too many redirects (${steps})`); + + await context.close(); + + // Фильтрация и ограничение объёма + const filteredDomains = Array.from(seenDomains) + .filter(d => !d.includes('doubleclick') && !d.includes('google')) + .sort(); + + return { + finalUrl, + relatedDomains: filteredDomains, + redirectChain: redirectLog, + droppedDomains, + }; + } catch (e) { + try { await context.close(); } catch {} + // Если браузер умер — перезапустим на следующем вызове + try { if (browser && !browser.isConnected()) { await browser.close(); browser = null; } } catch {} + throw e; + } finally { + page.off('request', onReq); + page.off('response', onResp); + } +} + +// ---------- Routes ---------- app.get('/domains', async (req, res) => { - const { domain } = req.query; - if (!domain) { - res.status(400).json({ error: '"domain" query parameter is required' }); + const norm = normalizeDomain(req.query.domain); + if (!norm) { + res.status(400).json({ error: '"domain" must be a valid hostname' }); return; } + + // Семафор — ограничиваем параллельность + const release = await sem.acquire(); + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), NAV_TIMEOUT_MS * 2); // общий верхний потолок + try { - const cached = getFromCache(domain); + const cached = getFromCache(norm); if (cached) { res.json({ - domain, + domain: norm, finalUrl: cached.finalUrl, relatedDomains: cached.relatedDomains, redirectChain: cached.redirectChain, @@ -201,23 +314,28 @@ app.get('/domains', async (req, res) => { return; } - const result = await scanDomainOnce(domain); - putToCache(domain, result); + const result = await scanDomainOnce(norm, ac.signal); + putToCache(norm, result); res.json({ - domain, + domain: norm, finalUrl: result.finalUrl, relatedDomains: result.relatedDomains, redirectChain: result.redirectChain, cached: false, + droppedDomains: result.droppedDomains, }); } catch (e) { res.status(500).json({ error: e.message || 'Internal server error' }); + } finally { + clearTimeout(timer); + release(); } }); app.get('/health', (_req, res) => res.json({ ok: true })); +// ---------- Shutdown ---------- process.on('SIGTERM', async () => { try { if (browser) await browser.close(); } catch {} process.exit(0); @@ -227,7 +345,7 @@ process.on('SIGINT', async () => { process.exit(0); }); -app.listen(port, () => { - console.log(`Domain scanner service listening on port ${port}`); +app.listen(PORT, () => { + console.log(`Domain scanner service listening on port ${PORT}`); }); From d3d848884e3baf371a3929ecf690aea69e1e1faf Mon Sep 17 00:00:00 2001 From: g00dvin Date: Fri, 12 Sep 2025 16:41:02 +0000 Subject: [PATCH 11/16] Add node dep --- package-lock.json | 27 ++++++++++++++++++++++++++- package.json | 8 ++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6a98bf3..3e08c4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "better-sqlite3": "^9.0.0", "express": "^4.18.2", + "express-rate-limit": "^8.1.0", "playwright": "^1.42.0" } }, @@ -377,6 +378,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.1.0.tgz", + "integrity": "sha512-4nLnATuKupnmwqiJc27b4dCFmB/T60ExgmtDD7waf4LdrbJ8CPZzZRHYErDYNhoz+ql8fUdYwM/opf90PoPAQA==", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -569,6 +587,14 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1190,4 +1216,3 @@ } } } - diff --git a/package.json b/package.json index c6a44ac..a69fb55 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "gekata", + "name": "playwright-domain-scanner", "version": "1.0.0", "description": "Service to find all domains loaded by webpage using Playwright", "main": "server.js", @@ -7,9 +7,9 @@ "start": "node server.js" }, "dependencies": { + "better-sqlite3": "^9.0.0", "express": "^4.18.2", - "playwright": "^1.42.0", - "better-sqlite3": "^9.0.0" + "express-rate-limit": "^8.1.0", + "playwright": "^1.42.0" } } - From 710c9d6b34e6d531fde5ab9ce9b90c0c90548c20 Mon Sep 17 00:00:00 2001 From: g00dvin Date: Sat, 13 Sep 2025 09:55:07 +0000 Subject: [PATCH 12/16] Old server logic --- server.js | 366 ++++++++++++++++-------------------------------------- 1 file changed, 109 insertions(+), 257 deletions(-) diff --git a/server.js b/server.js index e5332ca..65bdc9c 100644 --- a/server.js +++ b/server.js @@ -1,107 +1,27 @@ -// server.js (hardened) +// server.js const express = require('express'); -const rateLimit = require('express-rate-limit'); const { chromium } = require('playwright'); const Database = require('better-sqlite3'); -const punycode = require('punycode/'); - -// ---------- Config ---------- -const PORT = Number(process.env.PORT || 3000); -const CHROMIUM_PATH = process.env.CHROMIUM_PATH || undefined; -const CACHE_TTL_SECONDS = parseInt(process.env.CACHE_TTL_SECONDS || '21600', 10); -const MAX_REDIRECT_STEPS = parseInt(process.env.MAX_REDIRECT_STEPS || '20', 10); -const CONCURRENCY = parseInt(process.env.CONCURRENCY || '3', 10); -const SQLITE_PATH = process.env.SQLITE_PATH || './cache.db'; -const MAX_DOMAINS = parseInt(process.env.MAX_DOMAINS || '5000', 10); -const MAX_REDIRECT_LOG = parseInt(process.env.MAX_REDIRECT_LOG || '50', 10); -const NAV_TIMEOUT_MS = parseInt(process.env.NAV_TIMEOUT_MS || '30000', 10); -const QUIET_WINDOW_MS = parseInt(process.env.QUIET_WINDOW_MS || '600', 10); // «маленькая тишина» - -const CHROMIUM_ARGS = [ +const app = express(); +const port = process.env.PORT || 3000; +const executablePath = process.env.CHROMIUM_PATH || undefined; +const chromiumArgs = [ '--no-sandbox', '--disable-setuid-sandbox', - '--disable-dev-shm-usage', // рекомендуется заменить на --ipc=host при запуске контейнера + '--disable-dev-shm-usage', '--disable-gpu', '--no-zygote', ]; - -// ---------- Helpers ---------- -const app = express(); -app.use(express.json()); - -// Basic rate limit (per-IP) -const limiter = rateLimit({ - windowMs: 60_000, - max: 30, - standardHeaders: true, - legacyHeaders: false, -}); -app.use(limiter); - -// Normalize/validate domain -function normalizeDomain(input) { - if (!input || typeof input !== 'string') return null; - const s = input.trim().toLowerCase(); - // запрет схем/путей — ожидается чистый host - try { - // Если пришёл URL, извлечь hostname - const u = new URL(/^https?:\/\//i.test(s) ? s : `https://${s}`); - const host = u.hostname; - // IDNA -> ASCII - const ascii = punycode.toASCII(host); - if (!ascii || ascii.length > 253) return null; - return ascii; - } catch { - // Попытка интерпретации как host напрямую - try { - const ascii = punycode.toASCII(s); - return ascii || null; - } catch { - return null; - } - } -} - -function extractDomain(url) { - try { return new URL(url).hostname.toLowerCase(); } catch { return null; } -} - -// ---------- Simple semaphore ---------- -class Semaphore { - constructor(limit) { - this.limit = limit; - this.active = 0; - this.queue = []; - } - acquire() { - return new Promise(resolve => { - const tryAcquire = () => { - if (this.active < this.limit) { - this.active++; - resolve(() => { - this.active--; - const next = this.queue.shift(); - if (next) next(); - }); - } else { - this.queue.push(tryAcquire); - } - }; - tryAcquire(); - }); - } -} -const sem = new Semaphore(CONCURRENCY); - -// ---------- DB ---------- -const db = new Database(SQLITE_PATH); +const CACHE_TTL_SECONDS = parseInt(process.env.CACHE_TTL_SECONDS || '21600', 10); +const MAX_REDIRECT_STEPS = parseInt(process.env.MAX_REDIRECT_STEPS || '20', 10); // анти-цикл по глубине +const db = new Database(process.env.SQLITE_PATH || './cache.db'); db.pragma('journal_mode = WAL'); db.exec(` CREATE TABLE IF NOT EXISTS domain_cache ( domain TEXT PRIMARY KEY, - result_json TEXT NOT NULL, + result_json TEXT NOT NULL, -- JSON массива связанных доменов final_url TEXT, - redirect_chain_json TEXT, + redirect_chain_json TEXT, -- JSON журнала редиректов updated_at INTEGER NOT NULL, ttl_at INTEGER NOT NULL ); @@ -120,7 +40,94 @@ ON CONFLICT(domain) DO UPDATE SET updated_at = excluded.updated_at, ttl_at = excluded.ttl_at `); - +app.use(express.json()); +function extractDomain(url) { + try { return new URL(url).hostname; } catch { return null; } +} +let browser; +async function getBrowser() { + if (browser && browser.isConnected()) return browser; + browser = await chromium.launch({ + executablePath, + headless: true, + args: chromiumArgs, + }); + return browser; +} +// Вспомогательная функция для сборки полного журнала редиректов через цепочку redirectedFrom() +function buildRedirectChainForResponse(resp) { + const chain = []; + const currentReq = resp.request(); + let prev = currentReq.redirectedFrom(); + let toUrl = currentReq.url(); + const status = resp.status(); + while (prev) { + chain.push({ from: prev.url(), to: toUrl, status }); + toUrl = prev.url(); + prev = prev.redirectedFrom(); + } + return chain.reverse(); +} +async function scanDomainOnce(originDomain) { + const startUrl = `https://${originDomain}`; + const b = await getBrowser(); + const context = await b.newContext(); + const page = await context.newPage(); + const seenDomains = new Set(); + const redirectLog = []; + const visitedUrls = new Set(); // для детекции циклов + let redirectSteps = 0; + // Фиксируем все запросы + page.on('request', req => { + const d = extractDomain(req.url()); + if (d) seenDomains.add(d); + }); + // Фиксируем ответы и редиректные цепочки + page.on('response', resp => { + const url = resp.url(); + const d = extractDomain(url); + if (d) seenDomains.add(d); + // Добавим элементы цепочки, если ответ был редиректом (3xx) + const status = resp.status(); + if (status >= 300 && status < 400) { + const piece = buildRedirectChainForResponse(resp); + redirectLog.push(...piece); + } + }); + try { + let currentUrl = startUrl; + // Анти-цикл: свой контроль над goto в несколько шагов — через ожидание события navigation и проверку URL + // Однако Playwright следует редиректам сам; для анти-цикла контролируем уникальность URL после перехода + const resp = await page.goto(currentUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); + // После авто-редиректов Playwright мы проверим фактическую цепочку через обработчики и page.url() + // Защита от «вечных» редиректов: проверим историю URL в performance entries + // Простой и надёжный способ: считать шаги смены URL в waitForNavigation с url predicate — но нам достаточно лимита по постфакту. + // Проверим финальный URL и убедимся, что не было явного зацикливания по уже виденным URL. + const finalUrl = page.url(); + if (visitedUrls.has(finalUrl)) { + throw new Error('Redirect loop detected'); + } + visitedUrls.add(finalUrl); + // Как дополнительная защита — лимит по шагам 3xx из собранного redirectLog + // Если цепочка слишком длинная, считаем её небезопасной. + redirectSteps = redirectLog.length; + if (redirectSteps > MAX_REDIRECT_STEPS) { + throw new Error(`Too many redirects (${redirectSteps})`); + } + await context.close(); + const relatedDomains = Array.from(seenDomains) + .filter(d => !d.includes('doubleclick') && !d.includes('google')) + .sort(); + return { + finalUrl, + relatedDomains, + redirectChain: redirectLog, + }; + } catch (e) { + try { await context.close(); } catch {} + throw e; + } +} function getFromCache(domain) { const row = stmtSelect.get(domain); if (!row) return null; @@ -149,161 +156,17 @@ function putToCache(domain, result) { ttl_at: ttlAt, }); } - -// ---------- Browser lifecycle ---------- -let browser; -async function ensureBrowser() { - try { - if (browser && browser.isConnected()) return browser; - } catch {} - if (browser) { - try { await browser.close(); } catch {} - } - browser = await chromium.launch({ - executablePath: CHROMIUM_PATH, - headless: true, - args: CHROMIUM_ARGS, - }); - return browser; -} - -// ---------- Redirect utilities ---------- -function buildRedirectChainForResponse(resp) { - const chain = []; - const currentReq = resp.request(); - let prev = currentReq.redirectedFrom(); - let toUrl = currentReq.url(); - const status = resp.status(); - while (prev) { - chain.push({ from: prev.url(), to: toUrl, status }); - toUrl = prev.url(); - prev = prev.redirectedFrom(); - if (chain.length >= MAX_REDIRECT_LOG) break; - } - return chain.reverse(); -} - -// ---------- Core scan ---------- -async function scanDomainOnce(originDomain, signal) { - const startUrl = `https://${originDomain}`; - const b = await ensureBrowser(); - const context = await b.newContext(); - const page = await context.newPage(); - - const seenDomains = new Set(); - const redirectLog = []; - const visitedUrls = new Set(); - const seenPairs = new Set(); // from|to для детекции петель - - // Бюджеты - let droppedDomains = 0; - - // Capture network - // Lightweight counter для «тихого» окна - let inflight = 0; - let lastNetChange = Date.now(); - - const onReq = req => { - inflight++; - lastNetChange = Date.now(); - const d = extractDomain(req.url()); - if (d) { - if (seenDomains.size < MAX_DOMAINS) seenDomains.add(d); - else droppedDomains++; - } - }; - const onResp = resp => { - inflight = Math.max(0, inflight - 1); - lastNetChange = Date.now(); - const url = resp.url(); - const d = extractDomain(url); - if (d) { - if (seenDomains.size < MAX_DOMAINS) seenDomains.add(d); - else droppedDomains++; - } - const status = resp.status(); - if (status >= 300 && status < 400) { - const piece = buildRedirectChainForResponse(resp); - for (const p of piece) { - if (redirectLog.length >= MAX_REDIRECT_LOG) break; - const key = `${p.from}|${p.to}`; - if (!seenPairs.has(key)) { - seenPairs.add(key); - redirectLog.push(p); - } else { - // петля - // ничего не делаем здесь — оценим ниже общим правилом - } - } - } - }; - - page.on('request', onReq); - page.on('response', onResp); - - try { - // Навигация: domcontentloaded, затем дождаться короткой «тишины» - await page.goto(startUrl, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS }); - - // Простейшее ожидание «тишины» сети, но с общим таймаутом - const startWait = Date.now(); - while (Date.now() - startWait < NAV_TIMEOUT_MS) { - if (signal?.aborted) throw new Error('Aborted'); - const quietFor = Date.now() - lastNetChange; - if (inflight === 0 && quietFor >= QUIET_WINDOW_MS) break; - await new Promise(r => setTimeout(r, 100)); - } - - const finalUrl = page.url(); - // Анти-цикл: повтор URL или превышение лимита шагов/пар - if (visitedUrls.has(finalUrl)) throw new Error('Redirect loop detected'); - visitedUrls.add(finalUrl); - - const steps = redirectLog.length; - if (steps > MAX_REDIRECT_STEPS) throw new Error(`Too many redirects (${steps})`); - - await context.close(); - - // Фильтрация и ограничение объёма - const filteredDomains = Array.from(seenDomains) - .filter(d => !d.includes('doubleclick') && !d.includes('google')) - .sort(); - - return { - finalUrl, - relatedDomains: filteredDomains, - redirectChain: redirectLog, - droppedDomains, - }; - } catch (e) { - try { await context.close(); } catch {} - // Если браузер умер — перезапустим на следующем вызове - try { if (browser && !browser.isConnected()) { await browser.close(); browser = null; } } catch {} - throw e; - } finally { - page.off('request', onReq); - page.off('response', onResp); - } -} - -// ---------- Routes ---------- app.get('/domains', async (req, res) => { - const norm = normalizeDomain(req.query.domain); - if (!norm) { - res.status(400).json({ error: '"domain" must be a valid hostname' }); + const { domain } = req.query; + if (!domain) { + res.status(400).json({ error: '"domain" query parameter is required' }); return; } - - // Семафор — ограничиваем параллельность - const release = await sem.acquire(); - const ac = new AbortController(); - const timer = setTimeout(() => ac.abort(), NAV_TIMEOUT_MS * 2); // общий верхний потолок - try { - const cached = getFromCache(norm); + const cached = getFromCache(domain); if (cached) { res.json({ - domain: norm, + domain, finalUrl: cached.finalUrl, relatedDomains: cached.relatedDomains, redirectChain: cached.redirectChain, @@ -313,29 +176,20 @@ app.get('/domains', async (req, res) => { }); return; } - - const result = await scanDomainOnce(norm, ac.signal); - putToCache(norm, result); - + const result = await scanDomainOnce(domain); + putToCache(domain, result); res.json({ - domain: norm, + domain, finalUrl: result.finalUrl, relatedDomains: result.relatedDomains, redirectChain: result.redirectChain, cached: false, - droppedDomains: result.droppedDomains, }); } catch (e) { res.status(500).json({ error: e.message || 'Internal server error' }); - } finally { - clearTimeout(timer); - release(); } }); - app.get('/health', (_req, res) => res.json({ ok: true })); - -// ---------- Shutdown ---------- process.on('SIGTERM', async () => { try { if (browser) await browser.close(); } catch {} process.exit(0); @@ -344,8 +198,6 @@ process.on('SIGINT', async () => { try { if (browser) await browser.close(); } catch {} process.exit(0); }); - -app.listen(PORT, () => { - console.log(`Domain scanner service listening on port ${PORT}`); +app.listen(port, () => { + console.log(`Domain scanner service listening on port ${port}`); }); - From 06929de8f6556f6f6a83fced836a6b5fcf53cf52 Mon Sep 17 00:00:00 2001 From: g00dvin Date: Sat, 13 Sep 2025 12:17:29 +0000 Subject: [PATCH 13/16] New logic of redirect. Add DEBUG var --- server.js | 463 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 378 insertions(+), 85 deletions(-) diff --git a/server.js b/server.js index 65bdc9c..7036f09 100644 --- a/server.js +++ b/server.js @@ -2,26 +2,46 @@ const express = require('express'); const { chromium } = require('playwright'); const Database = require('better-sqlite3'); +const punycode = require('punycode/'); const app = express(); -const port = process.env.PORT || 3000; -const executablePath = process.env.CHROMIUM_PATH || undefined; -const chromiumArgs = [ + +// ---------- Config ---------- +const PORT = Number(process.env.PORT || 3000); +const CHROMIUM_PATH = process.env.CHROMIUM_PATH || undefined; +const CACHE_TTL_SECONDS = parseInt(process.env.CACHE_TTL_SECONDS || '21600', 10); +// Важно: это реальный лимит редиректов для документной навигации +const MAX_REDIRECT_STEPS = parseInt(process.env.MAX_REDIRECT_STEPS || '20', 10); +const NAV_TIMEOUT_MS = parseInt(process.env.NAV_TIMEOUT_MS || '30000', 10); +const QUIET_WINDOW_MS = parseInt(process.env.QUIET_WINDOW_MS || '700', 10); +const PRECHECK_MAX_REDIRECTS = parseInt(process.env.PRECHECK_MAX_REDIRECTS || '15', 10); +const SQLITE_PATH = process.env.SQLITE_PATH || './cache.db'; +const DEBUG_ENABLED = String(process.env.DEBUG || '').trim() === '1'; +const CHROMIUM_ARGS = [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--no-zygote', ]; -const CACHE_TTL_SECONDS = parseInt(process.env.CACHE_TTL_SECONDS || '21600', 10); -const MAX_REDIRECT_STEPS = parseInt(process.env.MAX_REDIRECT_STEPS || '20', 10); // анти-цикл по глубине -const db = new Database(process.env.SQLITE_PATH || './cache.db'); + +// ---------- Logging ---------- +const log = { + info: (...a) => console.log(...a), + debug: (...a) => { if (DEBUG_ENABLED) console.log(...a); }, + warn: (...a) => console.warn(...a), + error: (...a) => console.error(...a), +}; + +// ---------- DB ---------- +log.info(`[BOOT] SQLite path: ${SQLITE_PATH}`); +const db = new Database(SQLITE_PATH); db.pragma('journal_mode = WAL'); db.exec(` CREATE TABLE IF NOT EXISTS domain_cache ( domain TEXT PRIMARY KEY, - result_json TEXT NOT NULL, -- JSON массива связанных доменов + result_json TEXT NOT NULL, final_url TEXT, - redirect_chain_json TEXT, -- JSON журнала редиректов + redirect_chain_json TEXT, updated_at INTEGER NOT NULL, ttl_at INTEGER NOT NULL ); @@ -40,132 +60,356 @@ ON CONFLICT(domain) DO UPDATE SET updated_at = excluded.updated_at, ttl_at = excluded.ttl_at `); + app.use(express.json()); + +// ---------- Helpers ---------- +function normalizeDomain(input) { + if (!input || typeof input !== 'string') return null; + const s = input.trim().toLowerCase(); + try { + const u = new URL(/^https?:\/\//i.test(s) ? s : `https://${s}`); + return punycode.toASCII(u.hostname) || null; + } catch { + try { return punycode.toASCII(s) || null; } catch { return null; } + } +} // [Express/Node JSON response patterns] [4] + function extractDomain(url) { - try { return new URL(url).hostname; } catch { return null; } + try { return new URL(url).hostname.toLowerCase(); } catch { return null; } } + +// эвристика «выглядит как файл» +function looksLikeFilePath(u) { + try { + const { pathname } = new URL(u); + return /\.(?:zip|pdf|png|jpe?g|gif|webp|svg|mp4|mp3|wav|csv|xlsx?|docx?|pptx?|exe|deb|rpm|apk|tar(?:\.gz)?|7z|gz|bz2)$/i.test(pathname); + } catch { return false; } +} + +// канонизация URL для детекции петель +function normalizeUrlForLoop(u) { + try { + const x = new URL(u); + x.hash = ''; + return x.toString(); + } catch { return u; } +} + +// ---------- Precheck: manual redirects & classification ---------- +async function precheckFollowManually(startUrl) { + let url = startUrl; + const visited = new Set(); + let sawHtmlHint = false; + for (let i = 0; i < PRECHECK_MAX_REDIRECTS; i++) { + const norm = normalizeUrlForLoop(url); + if (visited.has(norm)) { + log.debug(`[PRECHECK] Loop at ${norm}`); + return { skip: true, reason: 'redirect-loop', tryBrowser: sawHtmlHint }; + } + visited.add(norm); + let res; + try { + res = await fetch(url, { method: 'GET', redirect: 'manual' }); + } catch (e) { + log.debug(`[PRECHECK] GET(manual) failed for ${url}: ${e?.message}`); + return { skip: false, reason: null, tryBrowser: false }; + } + const status = res.status; + const ct = res.headers.get('content-type') || ''; + const cd = res.headers.get('content-disposition') || ''; + const loc = res.headers.get('location') || ''; + log.debug(`[PRECHECK] step=${i} status=${status} ct="${ct}" cd="${cd || '-'}" loc="${loc || '-'}"`); + const isHtml = /\btext\/html\b/i.test(ct); + if (isHtml) sawHtmlHint = true; + const isAttachment = /attachment/i.test(cd); + if (status === 403) { + return { skip: true, reason: 'forbidden', tryBrowser: true }; + } + if (status >= 300 && status < 400 && loc) { + const next = new URL(loc, url).toString(); + if (looksLikeFilePath(next) || /download|file|export/i.test(next)) { + return { skip: true, reason: `redirect-to-file(${next})`, tryBrowser: false, finalUrl: next }; + } + try { + const probe = await fetch(next, { method: 'GET', redirect: 'manual' }); + const pct = probe.headers.get('content-type') || ''; + const isHtmlTarget = /\btext\/html\b/i.test(pct); + if (isHtmlTarget) { + return { skip: true, reason: `marketing-redirect(${next})`, tryBrowser: false, finalUrl: next }; + } + } catch {} + url = next; + continue; + } + if (isAttachment) return { skip: true, reason: 'attachment', tryBrowser: false, finalUrl: url }; + if (!isHtml && ct) return { skip: true, reason: `non-HTML (${ct})`, tryBrowser: false, finalUrl: url }; + return { skip: false, reason: null, tryBrowser: false, finalUrl: url }; + } + log.debug(`[PRECHECK] Too many redirects >= ${PRECHECK_MAX_REDIRECTS}`); + return { skip: true, reason: `redirect-loop(${PRECHECK_MAX_REDIRECTS})`, tryBrowser: sawHtmlHint, finalUrl: null }; +} // [Navigations & heuristics / handling redirects] [4] + +// ---------- Browser lifecycle ---------- let browser; -async function getBrowser() { +async function ensureBrowser() { if (browser && browser.isConnected()) return browser; - browser = await chromium.launch({ - executablePath, - headless: true, - args: chromiumArgs, - }); + if (browser) { try { await browser.close(); } catch {} } + log.info(`[BROWSER] Launch headless Chromium`); + browser = await chromium.launch({ executablePath: CHROMIUM_PATH, headless: true, args: CHROMIUM_ARGS }); return browser; -} -// Вспомогательная функция для сборки полного журнала редиректов через цепочку redirectedFrom() -function buildRedirectChainForResponse(resp) { +} // [Playwright best practices] [13] + +// ---------- Redirect chain builder (document-only) ---------- +function buildRedirectChainForResponse(resp, maxLen = 50) { const chain = []; - const currentReq = resp.request(); - let prev = currentReq.redirectedFrom(); - let toUrl = currentReq.url(); + // Учитываем цепочку только для документной навигации + const req = resp.request(); + if (req.resourceType() !== 'document') return chain; + let prev = req.redirectedFrom(); + let toUrl = req.url(); const status = resp.status(); while (prev) { chain.push({ from: prev.url(), to: toUrl, status }); toUrl = prev.url(); prev = prev.redirectedFrom(); + if (chain.length >= maxLen) break; } return chain.reverse(); -} -async function scanDomainOnce(originDomain) { - const startUrl = `https://${originDomain}`; - const b = await getBrowser(); - const context = await b.newContext(); +} // [Playwright Request.redirectedFrom usage] [12] + +// ---------- Quiet network window ---------- +async function quietWindowWait({ inflightRef, lastChangeRef, timeoutMs, quietMs }) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const quietFor = Date.now() - lastChangeRef.value; + if (inflightRef.value === 0 && quietFor >= quietMs) return; + await new Promise(r => setTimeout(r, 100)); + } +} // [Wait strategy guidance] [14] + +// ---------- Core scan with Playwright ---------- +async function scanWithBrowser(originDomain, startUrl, contextOpts = {}) { + const b = await ensureBrowser(); + const context = await b.newContext({ acceptDownloads: true, ...contextOpts }); + + // Глобальный лимитер редиректов для документных навигаций: + // - для isNavigationRequest() с resourceType 'document' используем route.fetch({ maxRedirects }) + // - ассеты пропускаем без ограничения, чтобы не ломать рендер + await context.route('**', async route => { + const request = route.request(); + const isDoc = request.resourceType() === 'document'; + const isNav = request.isNavigationRequest(); + if (isDoc && isNav) { + try { + const response = await route.fetch({ maxRedirects: MAX_REDIRECT_STEPS }); + return route.fulfill({ response }); + } catch (e) { + // Если maxRedirects сработал, прерываем навигацию «аккуратно» + return route.fulfill({ + status: 508, + body: 'Loop Detected: too many redirects' + }); + } + } + return route.continue(); + }); // [Limit redirects for page.goto via routing] [4][5] + const page = await context.newPage(); + const seenDomains = new Set(); const redirectLog = []; - const visitedUrls = new Set(); // для детекции циклов - let redirectSteps = 0; - // Фиксируем все запросы - page.on('request', req => { + const visitedUrls = new Set(); + const inflightRef = { value: 0 }; + const lastChangeRef = { value: Date.now() }; + + if (DEBUG_ENABLED) { + page.on('console', msg => log.debug(`[PAGE.CONSOLE] ${msg.type()}: ${msg.text()}`)); + page.on('pageerror', err => log.debug(`[PAGE.ERROR] ${err?.message}`)); + page.on('requestfailed', req => log.debug(`[REQ.FAIL] ${req.url()} reason=${req.failure()?.errorText}`)); + } // [Console/request monitoring] [13] + + page.on('download', async dl => { + try { await dl.failure().catch(() => {}); } catch {} + log.debug(`[SCAN] Download ignored: ${dl.url()}`); + }); // [Downloads handling] [13] + + const onReq = req => { + inflightRef.value++; + lastChangeRef.value = Date.now(); const d = extractDomain(req.url()); if (d) seenDomains.add(d); - }); - // Фиксируем ответы и редиректные цепочки - page.on('response', resp => { - const url = resp.url(); - const d = extractDomain(url); + log.debug(`[REQ] ${req.method()} ${req.url()}`); + }; + const onResp = resp => { + inflightRef.value = Math.max(0, inflightRef.value - 1); + lastChangeRef.value = Date.now(); + const d = extractDomain(resp.url()); if (d) seenDomains.add(d); - // Добавим элементы цепочки, если ответ был редиректом (3xx) const status = resp.status(); - if (status >= 300 && status < 400) { - const piece = buildRedirectChainForResponse(resp); + log.debug(`[RESP] ${status} ${resp.url()}`); + // только документные редиректы считаем в цепочку + if (status >= 300 && status < 400 && resp.request().resourceType() === 'document') { + const piece = buildRedirectChainForResponse(resp, MAX_REDIRECT_STEPS + 5); redirectLog.push(...piece); } - }); + }; + page.on('request', onReq); + page.on('response', onResp); + try { - let currentUrl = startUrl; - // Анти-цикл: свой контроль над goto в несколько шагов — через ожидание события navigation и проверку URL - // Однако Playwright следует редиректам сам; для анти-цикла контролируем уникальность URL после перехода - const resp = await page.goto(currentUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); - // После авто-редиректов Playwright мы проверим фактическую цепочку через обработчики и page.url() - // Защита от «вечных» редиректов: проверим историю URL в performance entries - // Простой и надёжный способ: считать шаги смены URL в waitForNavigation с url predicate — но нам достаточно лимита по постфакту. - // Проверим финальный URL и убедимся, что не было явного зацикливания по уже виденным URL. + log.info(`[SCAN] goto(${startUrl}) domcontentloaded timeout=${NAV_TIMEOUT_MS}`); + let response; + try { + response = await page.goto(startUrl, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS }); + } catch (e) { + const msg = String(e?.message || ''); + if (/Download is starting/i.test(msg)) { + log.info(`[SCAN] goto triggered download; continue as non-HTML`); + } else { + throw e; + } + } + + // Если наш «ограничитель» вернул 508 — считаем как превышение редиректов + if (response && response.status() === 508) { + throw new Error(`Too many redirects (${MAX_REDIRECT_STEPS})`); + } + + await quietWindowWait({ inflightRef, lastChangeRef, timeoutMs: NAV_TIMEOUT_MS, quietMs: QUIET_WINDOW_MS }); const finalUrl = page.url(); - if (visitedUrls.has(finalUrl)) { - throw new Error('Redirect loop detected'); - } + + if (visitedUrls.has(finalUrl)) throw new Error('Redirect loop detected'); visitedUrls.add(finalUrl); - // Как дополнительная защита — лимит по шагам 3xx из собранного redirectLog - // Если цепочка слишком длинная, считаем её небезопасной. - redirectSteps = redirectLog.length; - if (redirectSteps > MAX_REDIRECT_STEPS) { - throw new Error(`Too many redirects (${redirectSteps})`); - } + + // Проверка цепочки только по документам + const steps = redirectLog.length; + if (steps > MAX_REDIRECT_STEPS) throw new Error(`Too many redirects (${steps})`); + await context.close(); + const relatedDomains = Array.from(seenDomains) .filter(d => !d.includes('doubleclick') && !d.includes('google')) .sort(); - return { - finalUrl, - relatedDomains, - redirectChain: redirectLog, - }; + + log.info(`[SCAN] Done finalUrl=${finalUrl} domains=${relatedDomains.length} redirects=${steps}`); + return { finalUrl, relatedDomains, redirectChain: redirectLog }; } catch (e) { try { await context.close(); } catch {} + try { + if (browser && typeof browser.isConnected === 'function' && !browser.isConnected()) { + await browser.close(); browser = null; + } + } catch {} + log.error(`[SCAN] Error: ${e?.message}`); throw e; + } finally { + page.off('request', onReq); + page.off('response', onResp); } } + +// ---------- High-level scan with precheck and escalation ---------- +async function scanDomainOnce(originDomain) { + const startUrl = `https://${originDomain}`; + log.info(`[SCAN] Start domain="${originDomain}" url=${startUrl}`); + const pre = await precheckFollowManually(startUrl); + + if (pre.skip && (pre.reason === 'attachment' || (pre.reason || '').startsWith('non-HTML'))) { + log.info(`[SCAN] Skip non-HTML/attachment: ${pre.reason}`); + return { finalUrl: pre.finalUrl || startUrl, relatedDomains: [originDomain], redirectChain: [], precheck: pre.reason }; + } + + let targetUrl = startUrl; + + if (pre.skip && /^marketing-redirect/.test(pre.reason || '') && pre.finalUrl) { + log.info(`[SCAN] Marketing redirect -> follow target in browser: ${pre.finalUrl}`); + targetUrl = pre.finalUrl; + } else if (pre.skip && pre.tryBrowser) { + log.info(`[SCAN] Escalation to browser due to ${pre.reason}`); + } + + const contextOpts = { + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + locale: 'en-US', + timezoneId: 'UTC', + }; + + try { + const result = await scanWithBrowser(originDomain, targetUrl, contextOpts); + if (!result.relatedDomains.includes(originDomain)) { + result.relatedDomains.unshift(originDomain); + } + return result; + } catch (e) { + log.warn(`[SCAN] Browser escalation failed: ${e?.message}`); + return { finalUrl: targetUrl, relatedDomains: [originDomain], redirectChain: [], precheck: pre.reason || 'blocked' }; + } +} + +// ---------- Cache helpers ---------- function getFromCache(domain) { const row = stmtSelect.get(domain); if (!row) return null; const now = Math.floor(Date.now() / 1000); if (row.ttl_at > now) { - return { - relatedDomains: JSON.parse(row.result_json), - finalUrl: row.final_url || null, - redirectChain: row.redirect_chain_json ? JSON.parse(row.redirect_chain_json) : [], - cached: true, - cachedAt: row.updated_at, - ttlAt: row.ttl_at, - }; + try { + const out = { + relatedDomains: JSON.parse(row.result_json), + finalUrl: row.final_url || null, + redirectChain: row.redirect_chain_json ? JSON.parse(row.redirect_chain_json) : [], + cached: true, + cachedAt: row.updated_at, + ttlAt: row.ttl_at, + }; + return out; + } catch (e) { + log.warn(`[CACHE] Parse error: ${e?.message}`); + return null; + } } return null; } function putToCache(domain, result) { const now = Math.floor(Date.now() / 1000); const ttlAt = now + CACHE_TTL_SECONDS; - stmtUpsert.run({ - domain, - result_json: JSON.stringify(result.relatedDomains || []), - final_url: result.finalUrl || null, - redirect_chain_json: JSON.stringify(result.redirectChain || []), - updated_at: now, - ttl_at: ttlAt, - }); + try { + stmtUpsert.run({ + domain, + result_json: JSON.stringify(result.relatedDomains || []), + final_url: result.finalUrl || null, + redirect_chain_json: JSON.stringify(result.redirectChain || []), + updated_at: now, + ttl_at: ttlAt, + }); + log.info(`[CACHE] Upsert ${domain} ttlAt=${ttlAt}`); + } catch (e) { + log.warn(`[CACHE] Upsert error: ${e?.message}`); + } } + +// ---------- Routes ---------- app.get('/domains', async (req, res) => { - const { domain } = req.query; + res.type('application/json'); + const raw = req.query.domain; + log.info(`[HTTP] /domains?domain=${raw}`); + const domain = normalizeDomain(raw); if (!domain) { - res.status(400).json({ error: '"domain" query parameter is required' }); + res.status(400).json({ error: '"domain" must be a valid hostname', code: 'BAD_DOMAIN' }); return; } + + const HARD_TIMEOUT = parseInt(process.env.HARD_TIMEOUT_MS || '70000', 10); + const hardTimer = setTimeout(() => { + try { if (!res.headersSent) res.status(504).json({ error: 'Gateway Timeout', code: 'TIMEOUT' }); } catch {} + }, HARD_TIMEOUT); + try { const cached = getFromCache(domain); if (cached) { - res.json({ + log.info(`[HTTP] Cache HIT ${domain}`); + res.status(200).json({ domain, finalUrl: cached.finalUrl, relatedDomains: cached.relatedDomains, @@ -173,31 +417,80 @@ app.get('/domains', async (req, res) => { cached: true, cachedAt: cached.cachedAt, ttlAt: cached.ttlAt, + status: 'ok' }); return; } + const result = await scanDomainOnce(domain); + + if (result.precheck) { + if ((result.precheck || '').startsWith('marketing-redirect')) { + res.status(200).json({ + domain, + finalUrl: result.finalUrl || `https://${domain}`, + relatedDomains: [domain], + redirectChain: [], + cached: false, + status: 'ok', + note: result.precheck + }); + return; + } + res.status(200).json({ + domain, + finalUrl: result.finalUrl || `https://${domain}`, + relatedDomains: [domain], + redirectChain: [], + cached: false, + status: (result.precheck === 'forbidden' || result.precheck === 'blocked') ? 'blocked' : 'skipped', + reason: result.precheck + }); + return; + } + putToCache(domain, result); - res.json({ + res.status(200).json({ domain, finalUrl: result.finalUrl, relatedDomains: result.relatedDomains, redirectChain: result.redirectChain, cached: false, + status: 'ok' }); } catch (e) { - res.status(500).json({ error: e.message || 'Internal server error' }); + const msg = String(e?.message || 'Internal error'); + log.error(`[HTTP] Error for ${domain}: ${msg}`); + const forbidden = /403|forbidden|blocked/i.test(msg); + res.status(forbidden ? 403 : 500).json({ + error: forbidden ? 'Forbidden' : 'Internal server error', + code: forbidden ? 'FORBIDDEN' : 'INTERNAL', + details: msg + }); + } finally { + clearTimeout(hardTimer); } }); -app.get('/health', (_req, res) => res.json({ ok: true })); + +app.get('/health', (_req, res) => { + res.type('application/json'); + res.json({ ok: true }); +}); + +// ---------- Signals ---------- process.on('SIGTERM', async () => { + log.info('[SIGNAL] SIGTERM'); try { if (browser) await browser.close(); } catch {} process.exit(0); }); process.on('SIGINT', async () => { + log.info('[SIGNAL] SIGINT'); try { if (browser) await browser.close(); } catch {} process.exit(0); }); -app.listen(port, () => { - console.log(`Domain scanner service listening on port ${port}`); + +// ---------- Start ---------- +app.listen(PORT, () => { + log.info(`Domain scanner service listening on port ${PORT}`); }); + From 35b0ff1cd5a9fcc04900a5b2c86ea15c92fb918b Mon Sep 17 00:00:00 2001 From: g00dvin Date: Sat, 13 Sep 2025 13:26:58 +0000 Subject: [PATCH 14/16] Udpate README.md --- README.md | 118 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2990f20..874aedf 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,117 @@ -# gekata +**Gekata** — это легковесный сервис на Node.js для извлечения «связных доменов» с веб-страниц, запускаемый в контейнере Debian с Chromium; он сначала делает предзапросы с ручным следованием редиректам, затем при необходимости поднимает безголовый браузер, ограничивает глубину редиректов и кэширует результаты в SQLite через better-sqlite3. Сервис предоставляет HTTP API /domains, возвращающее финальный URL, цепочку редиректов и список связанных доменов, а также /health для проверки готовности. -Extract all domains from site \ No newline at end of file +### Назначение + +Gekata сканирует заданный домен, разрешает маркетинговые и другие редиректы до целевой HTML-страницы, загружает её в безголовом Chromium и собирает множество доменных имён из всех сетевых запросов страницы, формируя список «связных доменов» для анализа интеграций, трекинга и CDN. Такой подход работает и для динамических сайтов с клиентским рендерингом. + +### Архитектура + +- Веб-сервер на Express предоставляет REST‑маршруты, принимает домен, валидирует его и инициирует сканирование, обрабатывая таймауты и коды ошибок. +- Предпроверка делает GET с ручным управлением редиректами, классифицируя сценарии: форс‑редиректы «маркетинга», запреты 403, скачивания и не‑HTML контент, чтобы экономить запуск браузера. +- Эскалация в безголовый Chromium (через Playwright) выполняет навигацию, применяя ограничение глубины редиректов только для документных переходов и ожидая «тихое окно» сети для стабильного сбора доменов. +- Кэширование результатов в SQLite с TTL ускоряет повторные запросы; используется лучшее для продакшна подключение better-sqlite3 и WAL‑журналирование для устойчивости. + + +### Потоки данных + +- Вход: GET /domains?domain= — принимает хост или URL, нормализует до ASCII/Punycode и формирует стартовый https:// URL. +- Предобработка: ручной обход 3xx с ограничением шагов; детекция «похожих на файл» ссылок и контента non‑HTML; маркетинговый редирект помечается и может быть целевой. +- Сканирование браузером: навигация на целевой URL, слежение за запросами/ответами страницы, сбор доменов из всех сетевых событий, исключая шум (google/doubleclick по эвристике), построение цепочки редиректов для документной навигации. +- Выход: JSON с finalUrl, relatedDomains[], redirectChain[], статусами ok/skipped/blocked и служебными пометками (cached, ttl). + + +### API + +- GET /domains +Параметры: domain — доменное имя или URL. +Ответ 200 ok: + - domain: нормализованный запрошенный домен. + - finalUrl: конечный URL после редиректов/навигации. + - relatedDomains: уникальные домены, замеченные при загрузке страницы. + - redirectChain: массив { from, to, status } для документных 3xx. + - cached: true/false, cachedAt, ttlAt. + - status: ok | skipped | blocked; дополнительные note/reason при skip/blocked. +- GET /health — простой JSON { ok: true } для readiness/liveness. + + +### Обработка редиректов + +- На этапе предпроверки ограничение PRECHECK_MAX_REDIRECTS предотвращает бесконечные цепочки до запуска браузера; 403 заставляет эскалировать в браузер, non‑HTML/attachment возвращают немедленный ответ. +- В браузере включён маршрут‑ограничитель только для документной навигации: запросы навигации обрабатываются с maxRedirects, ассеты идут без ограничений, чтобы не ломать рендеринг. +- Если лимит превышен, навигация завершается контролируемо и возвращается ошибка «Too many redirects», переводимая в понятный статус ответа API. + + +### Кэш и TTL + +- SQLite таблица domain_cache хранит: домен, JSON списка доменов, финальный URL, цепочку редиректов, время обновления и ttl_at. +- Повторные обращения до истечения TTL возвращают сохранённый результат без запуска браузера, снижая задержки и нагрузку. + + +### Контейнеризация + +- Образ состоит из двух стадий: builder и runtime, обе на debian:bookworm-slim. +- Стадия builder устанавливает Node.js, компилятор и заголовки SQLite для сборки native‑модуля better‑sqlite3, затем выполняет npm ci с пропуском dev‑зависимостей и копирует исходники. +- Стадия runtime устанавливает tini как корректный PID 1, Node.js runtime, системный Chromium и минимальный набор X/GTK/NSS/GBM/шрифтов, необходимых для безголового режима; копируются node_modules и исходники из builder. +- Создаётся непривилегированный пользователь nodeuser; директория приложения принадлежит ему; сервис запускается не от root. + + +### Переменные окружения + +- PORT — порт HTTP сервера (по умолчанию 3000). +- CHROMIUM_PATH — путь к системному Chromium (/usr/bin/chromium в контейнере). +- CACHE_TTL_SECONDS — срок жизни кэша (по умолчанию 6 часов). +- HARD_TIMEOUT_MS — жёсткий таймаут обработки HTTP‑запроса (по умолчанию 70 секунд). +- MAX_REDIRECT_STEPS — максимальная глубина редиректов для документной навигации (по умолчанию 20). +- NAV_TIMEOUT_MS, QUIET_WINDOW_MS — таймауты навигации и «тихого окна» сети. +- DEBUG — включает подробные логи страницы/сетевых событий при значении 1. + + +### Безопасность и устойчивость + +- tini как init обрабатывает сигналы и «зомби» процессы; контейнер корректно завершает Chromium по SIGTERM/SIGINT, предотвращая утечки. +- Запуск под непривилегированным пользователем снижает риск компрометации; Chromium стартует с флагами no‑sandbox/disable‑setuid-sandbox, что совместимо с безпривилегированным окружением контейнеров. +- Ограничение редиректов для документных переходов устраняет зацикливание «маркетинговых» и неверных конфигураций, не влияя на загрузку ассетов. + + +### Производительность + +- npm ci в builder‑стадии плюс копирование package*.json до исходников задействуют кэш слоёв Docker, ускоряя сборки. +- better‑sqlite3 с синхронными подготовленными выражениями обеспечивает быстрый локальный кэш без отдельного сервиса БД. +- Предпроверка HTTP избавляет от лишних подъёмов браузера для не‑HTML или «прикреплённых» ответов. + + +### Сборка и запуск + +- Сборка образа: + - docker build -t gekata:latest . +- Запуск контейнера: + - docker run --rm -p 3000:3000 -e CACHE_TTL_SECONDS=21600 -e MAX_REDIRECT_STEPS=20 gekata:latest +- Примеры запросов: + - curl -s "http://localhost:3000/health" + - curl -s "http://localhost:3000/domains?domain=forum.xda-developers.com" + + +### Журналирование и диагностика + +- Лог‑метки [BOOT], [HTTP], [SCAN], [BROWSER], [CACHE], [SIGNAL] позволяют быстро локализовать этап и тип события. +- При включённом DEBUG=1 логируются консоль страницы, ошибки, неудавшиеся запросы и сетевые эвенты, что помогает анализировать блокировки, CORS, антибот‑защиту и таймауты. + + +### Ограничения + +- Сайты с жёсткими антибот‑мерами (403/JS‑челленджи) могут быть помечены как blocked или потребовать дополнительной эмуляции (например, иные user‑agent/locale/timezone/proxy). +- Сбор связанных доменов базируется на фактически выполненных сетевых запросах и может меняться при A/B тестах, гео‑таргетинге или различиях по user‑agent. + + +### Расширения и доработки + +- Добавить белый/чёрный список доменов, тонкую фильтрацию трекеров и интеграций. +- Вынести кэш в внешний SQLite‑файл через volume для сохранения между рестартами, настроить резервное копирование. +- Параметризовать user‑agent/locale/timezone и добавить поддержку прокси для региональных сценариев. +- Экспортировать полный сетевой журнал и тайминги (HAR‑подобный формат) как опциональную выгрузку. + + +### Файлы проекта + +- server.js — основной сервис, логика API, предобработка, сканирование браузером, кэш, ограничения редиректов, завершение по сигналам. +- Dockerfile — двухстадийная сборка, системный Chromium в рантайме, tini, непривилегированный пользователь, переменные окружения и запуск службы. From a038862553204deb2851eaca1818d2332399382d Mon Sep 17 00:00:00 2001 From: g00dvin Date: Mon, 15 Sep 2025 16:27:37 +0000 Subject: [PATCH 15/16] Remove punnycode from server code, unlink old pcode library --- .dockerignore | 13 ++++++++ server.js | 82 ++++++++++++++++++++++++++++++++------------------- 2 files changed, 65 insertions(+), 30 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..229f78d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +* + +!package.json + +!package-lock.json + +!server.js + +!ignore-domains.txt + +!LICENSE + +!README.md diff --git a/server.js b/server.js index 7036f09..bdd242b 100644 --- a/server.js +++ b/server.js @@ -2,14 +2,15 @@ const express = require('express'); const { chromium } = require('playwright'); const Database = require('better-sqlite3'); -const punycode = require('punycode/'); +// Убираем punycode; используем WHATWG URL + domainToASCII +const { URL, domainToASCII } = require('node:url'); + const app = express(); // ---------- Config ---------- const PORT = Number(process.env.PORT || 3000); const CHROMIUM_PATH = process.env.CHROMIUM_PATH || undefined; const CACHE_TTL_SECONDS = parseInt(process.env.CACHE_TTL_SECONDS || '21600', 10); -// Важно: это реальный лимит редиректов для документной навигации const MAX_REDIRECT_STEPS = parseInt(process.env.MAX_REDIRECT_STEPS || '20', 10); const NAV_TIMEOUT_MS = parseInt(process.env.NAV_TIMEOUT_MS || '30000', 10); const QUIET_WINDOW_MS = parseInt(process.env.QUIET_WINDOW_MS || '700', 10); @@ -68,12 +69,22 @@ function normalizeDomain(input) { if (!input || typeof input !== 'string') return null; const s = input.trim().toLowerCase(); try { - const u = new URL(/^https?:\/\//i.test(s) ? s : `https://${s}`); - return punycode.toASCII(u.hostname) || null; + // Если это URL, берём hostname; иначе считаем, что это просто хост + const asUrl = /^https?:\/\//i.test(s) ? s : `https://${s}`; + const u = new URL(asUrl); + // Преобразуем к IDNA ASCII (Punycode) через WHATWG util + const ascii = domainToASCII(u.hostname || ''); + return ascii || null; } catch { - try { return punycode.toASCII(s) || null; } catch { return null; } + // Попытка прямой IDNA-конверсии из строки (на случай голого хоста без схемы) + try { + const ascii = domainToASCII(s); + return ascii || null; + } catch { + return null; + } } -} // [Express/Node JSON response patterns] [4] +} // WHATWG URL + url.domainToASCII [web:167][web:161][web:164] function extractDomain(url) { try { return new URL(url).hostname.toLowerCase(); } catch { return null; } @@ -148,7 +159,7 @@ async function precheckFollowManually(startUrl) { } log.debug(`[PRECHECK] Too many redirects >= ${PRECHECK_MAX_REDIRECTS}`); return { skip: true, reason: `redirect-loop(${PRECHECK_MAX_REDIRECTS})`, tryBrowser: sawHtmlHint, finalUrl: null }; -} // [Navigations & heuristics / handling redirects] [4] +} // [web:167] // ---------- Browser lifecycle ---------- let browser; @@ -158,12 +169,11 @@ async function ensureBrowser() { log.info(`[BROWSER] Launch headless Chromium`); browser = await chromium.launch({ executablePath: CHROMIUM_PATH, headless: true, args: CHROMIUM_ARGS }); return browser; -} // [Playwright best practices] [13] +} // [web:151] // ---------- Redirect chain builder (document-only) ---------- function buildRedirectChainForResponse(resp, maxLen = 50) { const chain = []; - // Учитываем цепочку только для документной навигации const req = resp.request(); if (req.resourceType() !== 'document') return chain; let prev = req.redirectedFrom(); @@ -176,7 +186,7 @@ function buildRedirectChainForResponse(resp, maxLen = 50) { if (chain.length >= maxLen) break; } return chain.reverse(); -} // [Playwright Request.redirectedFrom usage] [12] +} // [web:151] // ---------- Quiet network window ---------- async function quietWindowWait({ inflightRef, lastChangeRef, timeoutMs, quietMs }) { @@ -186,34 +196,49 @@ async function quietWindowWait({ inflightRef, lastChangeRef, timeoutMs, quietMs if (inflightRef.value === 0 && quietFor >= quietMs) return; await new Promise(r => setTimeout(r, 100)); } -} // [Wait strategy guidance] [14] +} // [web:151] // ---------- Core scan with Playwright ---------- async function scanWithBrowser(originDomain, startUrl, contextOpts = {}) { const b = await ensureBrowser(); const context = await b.newContext({ acceptDownloads: true, ...contextOpts }); - // Глобальный лимитер редиректов для документных навигаций: - // - для isNavigationRequest() с resourceType 'document' используем route.fetch({ maxRedirects }) - // - ассеты пропускаем без ограничения, чтобы не ломать рендер + // Безопасный лимитер редиректов для документной навигации await context.route('**', async route => { const request = route.request(); const isDoc = request.resourceType() === 'document'; const isNav = request.isNavigationRequest(); - if (isDoc && isNav) { + if (!(isDoc && isNav)) return route.continue(); + try { + const resp = await route.fetch({ maxRedirects: MAX_REDIRECT_STEPS }); + const status = resp.status(); + const headers = await resp.headers(); + const body = await resp.body().catch(() => null); try { - const response = await route.fetch({ maxRedirects: MAX_REDIRECT_STEPS }); - return route.fulfill({ response }); + await route.fulfill({ status, headers, body }); } catch (e) { - // Если maxRedirects сработал, прерываем навигацию «аккуратно» - return route.fulfill({ - status: 508, - body: 'Loop Detected: too many redirects' - }); + log.debug(`[ROUTE] fulfill failed for ${request.url()}: ${e?.message || e}`); + await route.continue(); + } + } catch (e) { + const msg = String(e?.message || ''); + if (/redirect/i.test(msg) || /too many/i.test(msg)) { + try { + await route.fulfill({ + status: 508, + contentType: 'text/plain', + body: 'Loop Detected: too many redirects' + }); + } catch (e2) { + log.debug(`[ROUTE] fulfill(508) failed for ${request.url()}: ${e2?.message || e2}`); + await route.continue(); + } + } else { + log.debug(`[ROUTE] fetch failed for ${request.url()}: ${msg}`); + await route.continue(); } } - return route.continue(); - }); // [Limit redirects for page.goto via routing] [4][5] + }); const page = await context.newPage(); @@ -227,12 +252,12 @@ async function scanWithBrowser(originDomain, startUrl, contextOpts = {}) { page.on('console', msg => log.debug(`[PAGE.CONSOLE] ${msg.type()}: ${msg.text()}`)); page.on('pageerror', err => log.debug(`[PAGE.ERROR] ${err?.message}`)); page.on('requestfailed', req => log.debug(`[REQ.FAIL] ${req.url()} reason=${req.failure()?.errorText}`)); - } // [Console/request monitoring] [13] + } page.on('download', async dl => { try { await dl.failure().catch(() => {}); } catch {} log.debug(`[SCAN] Download ignored: ${dl.url()}`); - }); // [Downloads handling] [13] + }); const onReq = req => { inflightRef.value++; @@ -248,7 +273,6 @@ async function scanWithBrowser(originDomain, startUrl, contextOpts = {}) { if (d) seenDomains.add(d); const status = resp.status(); log.debug(`[RESP] ${status} ${resp.url()}`); - // только документные редиректы считаем в цепочку if (status >= 300 && status < 400 && resp.request().resourceType() === 'document') { const piece = buildRedirectChainForResponse(resp, MAX_REDIRECT_STEPS + 5); redirectLog.push(...piece); @@ -271,8 +295,7 @@ async function scanWithBrowser(originDomain, startUrl, contextOpts = {}) { } } - // Если наш «ограничитель» вернул 508 — считаем как превышение редиректов - if (response && response.status() === 508) { + if (response && response.status && response.status() === 508) { throw new Error(`Too many redirects (${MAX_REDIRECT_STEPS})`); } @@ -282,7 +305,6 @@ async function scanWithBrowser(originDomain, startUrl, contextOpts = {}) { if (visitedUrls.has(finalUrl)) throw new Error('Redirect loop detected'); visitedUrls.add(finalUrl); - // Проверка цепочки только по документам const steps = redirectLog.length; if (steps > MAX_REDIRECT_STEPS) throw new Error(`Too many redirects (${steps})`); From d6d6027a17d4a885491bdef9d95af537e0ae0fa2 Mon Sep 17 00:00:00 2001 From: g00dvin Date: Fri, 19 Sep 2025 13:47:36 +0000 Subject: [PATCH 16/16] Add yandex to ignore domains --- ignore-domains.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ignore-domains.txt b/ignore-domains.txt index bf4888e..d6033b8 100644 --- a/ignore-domains.txt +++ b/ignore-domains.txt @@ -1,3 +1,3 @@ doubleclick google - +yandex