mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-11 13:42:06 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd4de7b995 | |||
| 832f06e86e | |||
| a7ba3d73db | |||
| e2a15aadcc | |||
| e3f00c103e | |||
| a501202ab6 | |||
| 166cb323dd | |||
| b7585ebf71 | |||
| 3205239067 | |||
| c5b3066962 | |||
| 84d7777246 | |||
| 8412392788 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.42.2"
|
||||
"version": "1.43.2"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -2,6 +2,30 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 1.43.2 - 2026-03-05
|
||||
|
||||
### Refactor
|
||||
- `baoyu-url-to-markdown`: replace custom HTML extraction (linkedom + Readability + Turndown) with defuddle library for cleaner content extraction and markdown conversion
|
||||
|
||||
## 1.43.1 - 2026-03-02
|
||||
|
||||
### Features
|
||||
- `baoyu-post-to-x`: auto-detect WSL environment and resolve Chrome profile to Windows-native path for stable login persistence
|
||||
- `baoyu-post-to-wechat`: auto-detect WSL environment and resolve Chrome profile to Windows-native path for stable login persistence
|
||||
- `baoyu-danger-gemini-web`: WSL auto-detection for Chrome profile path; add `GEMINI_WEB_DEBUG_PORT` env var for fixed debug port
|
||||
- `baoyu-danger-x-to-markdown`: WSL auto-detection for Chrome profile path; add `X_DEBUG_PORT` env var for fixed debug port
|
||||
|
||||
## 1.43.0 - 2026-03-02
|
||||
|
||||
### Features
|
||||
- `baoyu-post-to-wechat`: support env var overrides for browser debug port (`WECHAT_BROWSER_DEBUG_PORT`) and profile directory (`WECHAT_BROWSER_PROFILE_DIR`)
|
||||
- `baoyu-post-to-x`: support env var overrides for browser debug port (`X_BROWSER_DEBUG_PORT`) and profile directory (`X_BROWSER_PROFILE_DIR`)
|
||||
|
||||
## 1.42.3 - 2026-03-02
|
||||
|
||||
### Fixes
|
||||
- `baoyu-image-gen`: use standard size presets for DashScope aspect ratio mapping instead of free-form calculation
|
||||
|
||||
## 1.42.2 - 2026-03-01
|
||||
|
||||
### Features
|
||||
|
||||
@@ -2,6 +2,30 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 1.43.2 - 2026-03-05
|
||||
|
||||
### 重构
|
||||
- `baoyu-url-to-markdown`:使用 defuddle 库替换自定义 HTML 提取逻辑(linkedom + Readability + Turndown),简化内容提取和 Markdown 转换
|
||||
|
||||
## 1.43.1 - 2026-03-02
|
||||
|
||||
### 新功能
|
||||
- `baoyu-post-to-x`:自动检测 WSL 环境,将 Chrome profile 路径解析为 Windows 本地路径,解决登录态丢失问题
|
||||
- `baoyu-post-to-wechat`:自动检测 WSL 环境,将 Chrome profile 路径解析为 Windows 本地路径,解决登录态丢失问题
|
||||
- `baoyu-danger-gemini-web`:WSL 自动检测 Chrome profile 路径;新增 `GEMINI_WEB_DEBUG_PORT` 环境变量支持固定调试端口
|
||||
- `baoyu-danger-x-to-markdown`:WSL 自动检测 Chrome profile 路径;新增 `X_DEBUG_PORT` 环境变量支持固定调试端口
|
||||
|
||||
## 1.43.0 - 2026-03-02
|
||||
|
||||
### 新功能
|
||||
- `baoyu-post-to-wechat`:支持通过环境变量覆盖浏览器调试端口(`WECHAT_BROWSER_DEBUG_PORT`)和配置目录(`WECHAT_BROWSER_PROFILE_DIR`)
|
||||
- `baoyu-post-to-x`:支持通过环境变量覆盖浏览器调试端口(`X_BROWSER_DEBUG_PORT`)和配置目录(`X_BROWSER_PROFILE_DIR`)
|
||||
|
||||
## 1.42.3 - 2026-03-02
|
||||
|
||||
### 修复
|
||||
- `baoyu-image-gen`:DashScope 宽高比映射改用标准预设尺寸匹配,避免自由计算产生无效分辨率
|
||||
|
||||
## 1.42.2 - 2026-03-01
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -91,6 +91,8 @@ class CdpConnection {
|
||||
}
|
||||
|
||||
async function get_free_port(): Promise<number> {
|
||||
const fixed = parseInt(process.env.GEMINI_WEB_DEBUG_PORT || '', 10);
|
||||
if (fixed > 0) return fixed;
|
||||
return await new Promise((resolve, reject) => {
|
||||
const srv = net.createServer();
|
||||
srv.unref();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
@@ -29,9 +30,22 @@ export function resolveGeminiWebCookiePath(): string {
|
||||
return path.join(resolveGeminiWebDataDir(), COOKIE_FILE_NAME);
|
||||
}
|
||||
|
||||
let _wslHome: string | null | undefined;
|
||||
function getWslWindowsHome(): string | null {
|
||||
if (_wslHome !== undefined) return _wslHome;
|
||||
if (!process.env.WSL_DISTRO_NAME) { _wslHome = null; return null; }
|
||||
try {
|
||||
const raw = execSync('cmd.exe /C "echo %USERPROFILE%"', { encoding: 'utf-8', timeout: 5000 }).trim().replace(/\r/g, '');
|
||||
_wslHome = execSync(`wslpath -u "${raw}"`, { encoding: 'utf-8', timeout: 5000 }).trim() || null;
|
||||
} catch { _wslHome = null; }
|
||||
return _wslHome;
|
||||
}
|
||||
|
||||
export function resolveGeminiWebChromeProfileDir(): string {
|
||||
const override = process.env.GEMINI_WEB_CHROME_PROFILE_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
const wslHome = getWslWindowsHome();
|
||||
if (wslHome) return path.join(wslHome, '.local', 'share', APP_DATA_DIR, GEMINI_DATA_DIR, PROFILE_DIR_NAME);
|
||||
return path.join(resolveGeminiWebDataDir(), PROFILE_DIR_NAME);
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,8 @@ class CdpConnection {
|
||||
}
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
const fixed = parseInt(process.env.X_DEBUG_PORT || "", 10);
|
||||
if (fixed > 0) return fixed;
|
||||
return await new Promise((resolve, reject) => {
|
||||
const srv = net.createServer();
|
||||
srv.unref();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
@@ -30,9 +31,22 @@ export function resolveXToMarkdownCookiePath(): string {
|
||||
return path.join(resolveXToMarkdownDataDir(), COOKIE_FILE_NAME);
|
||||
}
|
||||
|
||||
let _wslHome: string | null | undefined;
|
||||
function getWslWindowsHome(): string | null {
|
||||
if (_wslHome !== undefined) return _wslHome;
|
||||
if (!process.env.WSL_DISTRO_NAME) { _wslHome = null; return null; }
|
||||
try {
|
||||
const raw = execSync('cmd.exe /C "echo %USERPROFILE%"', { encoding: 'utf-8', timeout: 5000 }).trim().replace(/\r/g, '');
|
||||
_wslHome = execSync(`wslpath -u "${raw}"`, { encoding: 'utf-8', timeout: 5000 }).trim() || null;
|
||||
} catch { _wslHome = null; }
|
||||
return _wslHome;
|
||||
}
|
||||
|
||||
export function resolveXToMarkdownChromeProfileDir(): string {
|
||||
const override = process.env.X_CHROME_PROFILE_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
const wslHome = getWslWindowsHome();
|
||||
if (wslHome) return path.join(wslHome, ".local", "share", APP_DATA_DIR, X_TO_MARKDOWN_DATA_DIR, PROFILE_DIR_NAME);
|
||||
return path.join(resolveXToMarkdownDataDir(), PROFILE_DIR_NAME);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,27 +22,53 @@ function parseAspectRatio(ar: string): { width: number; height: number } | null
|
||||
return { width: w, height: h };
|
||||
}
|
||||
|
||||
function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||
const baseSize = quality === "2k" ? 1440 : 1024;
|
||||
const STANDARD_SIZES: [number, number][] = [
|
||||
[1024, 1024],
|
||||
[1280, 720],
|
||||
[720, 1280],
|
||||
[1024, 768],
|
||||
[768, 1024],
|
||||
[1536, 1024],
|
||||
[1024, 1536],
|
||||
[1536, 864],
|
||||
[864, 1536],
|
||||
];
|
||||
|
||||
if (!ar) return `${baseSize}*${baseSize}`;
|
||||
const STANDARD_SIZES_2K: [number, number][] = [
|
||||
[1536, 1536],
|
||||
[2048, 1152],
|
||||
[1152, 2048],
|
||||
[1536, 1024],
|
||||
[1024, 1536],
|
||||
[1536, 864],
|
||||
[864, 1536],
|
||||
[2048, 2048],
|
||||
];
|
||||
|
||||
function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||
const is2k = quality === "2k";
|
||||
const defaultSize = is2k ? "1536*1536" : "1024*1024";
|
||||
|
||||
if (!ar) return defaultSize;
|
||||
|
||||
const parsed = parseAspectRatio(ar);
|
||||
if (!parsed) return `${baseSize}*${baseSize}`;
|
||||
if (!parsed) return defaultSize;
|
||||
|
||||
const ratio = parsed.width / parsed.height;
|
||||
const targetRatio = parsed.width / parsed.height;
|
||||
const sizes = is2k ? STANDARD_SIZES_2K : STANDARD_SIZES;
|
||||
|
||||
if (Math.abs(ratio - 1) < 0.1) {
|
||||
return `${baseSize}*${baseSize}`;
|
||||
let best = defaultSize;
|
||||
let bestDiff = Infinity;
|
||||
|
||||
for (const [w, h] of sizes) {
|
||||
const diff = Math.abs(w / h - targetRatio);
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff;
|
||||
best = `${w}*${h}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (ratio > 1) {
|
||||
const w = Math.round(baseSize * ratio);
|
||||
return `${w}*${baseSize}`;
|
||||
}
|
||||
|
||||
const h = Math.round(baseSize / ratio);
|
||||
return `${baseSize}*${h}`;
|
||||
return best;
|
||||
}
|
||||
|
||||
function normalizeSize(size: string): string {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { execSync, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdir, readdir } from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
@@ -109,6 +109,8 @@ function sleep(ms: number): Promise<void> {
|
||||
}
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
const fixed = parseInt(process.env.WECHAT_BROWSER_DEBUG_PORT || '', 10);
|
||||
if (fixed > 0) return fixed;
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
@@ -169,8 +171,22 @@ function findChromeExecutable(): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let _wslHome: string | null | undefined;
|
||||
function getWslWindowsHome(): string | null {
|
||||
if (_wslHome !== undefined) return _wslHome;
|
||||
if (!process.env.WSL_DISTRO_NAME) { _wslHome = null; return null; }
|
||||
try {
|
||||
const raw = execSync('cmd.exe /C "echo %USERPROFILE%"', { encoding: 'utf-8', timeout: 5000 }).trim().replace(/\r/g, '');
|
||||
_wslHome = execSync(`wslpath -u "${raw}"`, { encoding: 'utf-8', timeout: 5000 }).trim() || null;
|
||||
} catch { _wslHome = null; }
|
||||
return _wslHome;
|
||||
}
|
||||
|
||||
function getDefaultProfileDir(): string {
|
||||
const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
const override = process.env.WECHAT_BROWSER_PROFILE_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
const home = getWslWindowsHome() ?? os.homedir();
|
||||
const base = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share');
|
||||
return path.join(base, 'wechat-browser-profile');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
@@ -69,8 +69,22 @@ export function findChromeExecutable(candidates: PlatformCandidates): string | u
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let _wslHome: string | null | undefined;
|
||||
function getWslWindowsHome(): string | null {
|
||||
if (_wslHome !== undefined) return _wslHome;
|
||||
if (!process.env.WSL_DISTRO_NAME) { _wslHome = null; return null; }
|
||||
try {
|
||||
const raw = execSync('cmd.exe /C "echo %USERPROFILE%"', { encoding: 'utf-8', timeout: 5000 }).trim().replace(/\r/g, '');
|
||||
_wslHome = execSync(`wslpath -u "${raw}"`, { encoding: 'utf-8', timeout: 5000 }).trim() || null;
|
||||
} catch { _wslHome = null; }
|
||||
return _wslHome;
|
||||
}
|
||||
|
||||
export function getDefaultProfileDir(): string {
|
||||
const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
const override = process.env.X_BROWSER_PROFILE_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
const home = getWslWindowsHome() ?? os.homedir();
|
||||
const base = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share');
|
||||
return path.join(base, 'x-browser-profile');
|
||||
}
|
||||
|
||||
@@ -79,6 +93,8 @@ export function sleep(ms: number): Promise<void> {
|
||||
}
|
||||
|
||||
export async function getFreePort(): Promise<number> {
|
||||
const fixed = parseInt(process.env.X_BROWSER_DEBUG_PORT || '', 10);
|
||||
if (fixed > 0) return fixed;
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { parseHTML } from "linkedom";
|
||||
import { Readability } from "@mozilla/readability";
|
||||
import TurndownService from "turndown";
|
||||
import { gfm } from "turndown-plugin-gfm";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { Defuddle } from "defuddle/node";
|
||||
|
||||
export interface PageMetadata {
|
||||
url: string;
|
||||
@@ -17,812 +15,51 @@ export interface ConversionResult {
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
interface ExtractionCandidate {
|
||||
title: string | null;
|
||||
byline: string | null;
|
||||
excerpt: string | null;
|
||||
published: string | null;
|
||||
html: string | null;
|
||||
textContent: string;
|
||||
method: string;
|
||||
}
|
||||
|
||||
type AnyRecord = Record<string, unknown>;
|
||||
|
||||
const MIN_CONTENT_LENGTH = 120;
|
||||
const GOOD_CONTENT_LENGTH = 900;
|
||||
|
||||
const CONTENT_SELECTORS = [
|
||||
"article",
|
||||
"main article",
|
||||
"[role='main'] article",
|
||||
"[itemprop='articleBody']",
|
||||
".article-content",
|
||||
".article-body",
|
||||
".post-content",
|
||||
".entry-content",
|
||||
".story-body",
|
||||
"main",
|
||||
"[role='main']",
|
||||
"#content",
|
||||
".content",
|
||||
];
|
||||
|
||||
const REMOVE_SELECTORS = [
|
||||
"script",
|
||||
"style",
|
||||
"noscript",
|
||||
"template",
|
||||
"iframe",
|
||||
"svg",
|
||||
"path",
|
||||
"nav",
|
||||
"aside",
|
||||
"footer",
|
||||
"header",
|
||||
"form",
|
||||
".advertisement",
|
||||
".ads",
|
||||
".social-share",
|
||||
".related-articles",
|
||||
".comments",
|
||||
".newsletter",
|
||||
".cookie-banner",
|
||||
".cookie-consent",
|
||||
"[role='navigation']",
|
||||
"[aria-label*='cookie' i]",
|
||||
];
|
||||
|
||||
const PUBLISHED_TIME_SELECTORS = [
|
||||
"meta[property='article:published_time']",
|
||||
"meta[name='pubdate']",
|
||||
"meta[name='publishdate']",
|
||||
"meta[name='date']",
|
||||
"time[datetime]",
|
||||
];
|
||||
|
||||
const ARTICLE_TYPES = new Set([
|
||||
"Article",
|
||||
"NewsArticle",
|
||||
"BlogPosting",
|
||||
"WebPage",
|
||||
"ReportageNewsArticle",
|
||||
]);
|
||||
|
||||
const NEXT_DATA_CONTENT_PATHS = [
|
||||
"props.pageProps.content.body",
|
||||
"props.pageProps.article.body",
|
||||
"props.pageProps.article.content",
|
||||
"props.pageProps.post.body",
|
||||
"props.pageProps.post.content",
|
||||
"props.pageProps.data.body",
|
||||
"props.pageProps.story.body.content",
|
||||
];
|
||||
|
||||
const cleanupAndExtractScriptBody = String.raw`
|
||||
(function () {
|
||||
export const absolutizeUrlsScript = String.raw`
|
||||
(function() {
|
||||
const baseUrl = document.baseURI || location.href;
|
||||
|
||||
function toAbsolute(url) {
|
||||
if (!url) return url;
|
||||
try {
|
||||
return new URL(url, baseUrl).href;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
try { return new URL(url, baseUrl).href; } catch { return url; }
|
||||
}
|
||||
|
||||
function absolutizeAttr(selector, attr) {
|
||||
document.querySelectorAll(selector).forEach((el) => {
|
||||
const value = el.getAttribute(attr);
|
||||
if (!value) return;
|
||||
const abs = toAbsolute(value);
|
||||
if (abs) el.setAttribute(attr, abs);
|
||||
function absAttr(sel, attr) {
|
||||
document.querySelectorAll(sel).forEach(el => {
|
||||
const v = el.getAttribute(attr);
|
||||
if (v) { const a = toAbsolute(v); if (a) el.setAttribute(attr, a); }
|
||||
});
|
||||
}
|
||||
|
||||
function absolutizeSrcset(selector) {
|
||||
document.querySelectorAll(selector).forEach((el) => {
|
||||
const srcset = el.getAttribute("srcset");
|
||||
if (!srcset) return;
|
||||
|
||||
const normalized = srcset
|
||||
.split(",")
|
||||
.map((part) => {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) return "";
|
||||
const pieces = trimmed.split(/\s+/);
|
||||
const url = pieces[0];
|
||||
const descriptor = pieces.slice(1).join(" ");
|
||||
const absoluteUrl = toAbsolute(url);
|
||||
return descriptor ? absoluteUrl + " " + descriptor : absoluteUrl;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
if (normalized) {
|
||||
el.setAttribute("srcset", normalized);
|
||||
}
|
||||
function absSrcset(sel) {
|
||||
document.querySelectorAll(sel).forEach(el => {
|
||||
const s = el.getAttribute("srcset");
|
||||
if (!s) return;
|
||||
el.setAttribute("srcset", s.split(",").map(p => {
|
||||
const t = p.trim(); if (!t) return "";
|
||||
const [url, ...d] = t.split(/\s+/);
|
||||
return d.length ? toAbsolute(url) + " " + d.join(" ") : toAbsolute(url);
|
||||
}).filter(Boolean).join(", "));
|
||||
});
|
||||
}
|
||||
|
||||
absolutizeAttr("a[href]", "href");
|
||||
absolutizeAttr("img[src], video[src], audio[src], source[src]", "src");
|
||||
absolutizeSrcset("img[srcset], source[srcset]");
|
||||
|
||||
const removeSelectors = [
|
||||
"noscript",
|
||||
"template",
|
||||
".cookie-banner",
|
||||
".cookie-consent",
|
||||
".consent-banner",
|
||||
"[aria-label*='cookie' i]",
|
||||
".advertisement",
|
||||
".ads"
|
||||
];
|
||||
|
||||
for (const sel of removeSelectors) {
|
||||
try {
|
||||
document.querySelectorAll(sel).forEach((el) => el.remove());
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function getMeta(names) {
|
||||
for (const name of names) {
|
||||
const el = document.querySelector('meta[name="' + name + '"]') || document.querySelector('meta[property="' + name + '"]');
|
||||
const content = el && el.getAttribute("content");
|
||||
if (content && content.trim()) return content.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function flattenJsonLdItems(value) {
|
||||
if (!value || typeof value !== "object") return [];
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap(flattenJsonLdItems);
|
||||
}
|
||||
|
||||
const obj = value;
|
||||
if (Array.isArray(obj["@graph"])) {
|
||||
return obj["@graph"].flatMap(flattenJsonLdItems);
|
||||
}
|
||||
|
||||
return [obj];
|
||||
}
|
||||
|
||||
function extractJsonLdMeta() {
|
||||
const scripts = document.querySelectorAll('script[type="application/ld+json"]');
|
||||
for (const script of scripts) {
|
||||
try {
|
||||
const parsed = JSON.parse(script.textContent || "");
|
||||
const items = flattenJsonLdItems(parsed);
|
||||
for (const item of items) {
|
||||
const rawType = Array.isArray(item["@type"]) ? item["@type"][0] : item["@type"];
|
||||
if (typeof rawType !== "string") continue;
|
||||
if (!["Article", "NewsArticle", "BlogPosting", "WebPage"].includes(rawType)) continue;
|
||||
|
||||
const author = (() => {
|
||||
if (typeof item.author === "string") return item.author;
|
||||
if (Array.isArray(item.author) && item.author.length > 0) {
|
||||
const first = item.author[0];
|
||||
return first && typeof first === "object" ? first.name : undefined;
|
||||
}
|
||||
if (item.author && typeof item.author === "object") {
|
||||
return item.author.name;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
return {
|
||||
title: item.headline || item.name,
|
||||
description: item.description,
|
||||
author: typeof author === "string" ? author : undefined,
|
||||
published: item.datePublished || item.dateCreated,
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const jsonLd = extractJsonLdMeta();
|
||||
|
||||
const timeEl = document.querySelector("time[datetime]");
|
||||
const title =
|
||||
getMeta(["og:title", "twitter:title"]) ||
|
||||
(typeof jsonLd.title === "string" ? jsonLd.title : undefined) ||
|
||||
document.querySelector("h1")?.textContent?.trim() ||
|
||||
document.title?.trim() ||
|
||||
"";
|
||||
|
||||
const description =
|
||||
getMeta(["description", "og:description", "twitter:description"]) ||
|
||||
(typeof jsonLd.description === "string" ? jsonLd.description : undefined);
|
||||
|
||||
const author =
|
||||
getMeta(["author", "article:author", "twitter:creator"]) ||
|
||||
(typeof jsonLd.author === "string" ? jsonLd.author : undefined);
|
||||
|
||||
const published =
|
||||
timeEl?.getAttribute("datetime") ||
|
||||
getMeta(["article:published_time", "datePublished", "publishdate", "date"]) ||
|
||||
(typeof jsonLd.published === "string" ? jsonLd.published : undefined);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
author,
|
||||
published,
|
||||
html: document.documentElement.outerHTML,
|
||||
};
|
||||
absAttr("a[href]", "href");
|
||||
absAttr("img[src], video[src], audio[src], source[src]", "src");
|
||||
absSrcset("img[srcset], source[srcset]");
|
||||
return { html: document.documentElement.outerHTML };
|
||||
})()
|
||||
`;
|
||||
|
||||
export const cleanupAndExtractScript = cleanupAndExtractScriptBody;
|
||||
export async function extractContent(html: string, url: string): Promise<ConversionResult> {
|
||||
const dom = new JSDOM(html, { url });
|
||||
const result = await Defuddle(dom, url, { markdown: true });
|
||||
|
||||
function pickString(...values: unknown[]): string | null {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function generateExcerpt(excerpt: string | null, textContent: string | null): string | null {
|
||||
if (excerpt) return excerpt;
|
||||
if (!textContent) return null;
|
||||
const trimmed = textContent.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.length > 200 ? `${trimmed.slice(0, 200)}...` : trimmed;
|
||||
}
|
||||
|
||||
function extractPublishedTime(document: any): string | null {
|
||||
for (const selector of PUBLISHED_TIME_SELECTORS) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) continue;
|
||||
const value = el.getAttribute("content") ?? el.getAttribute("datetime");
|
||||
if (value && value.trim()) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTitle(document: any): string | null {
|
||||
const ogTitle = document.querySelector("meta[property='og:title']")?.getAttribute("content");
|
||||
if (ogTitle && ogTitle.trim()) return ogTitle.trim();
|
||||
|
||||
const twitterTitle = document
|
||||
.querySelector("meta[name='twitter:title']")
|
||||
?.getAttribute("content");
|
||||
if (twitterTitle && twitterTitle.trim()) return twitterTitle.trim();
|
||||
|
||||
const title = document.querySelector("title")?.textContent?.trim();
|
||||
if (title) {
|
||||
const cleaned = title.split(/\s*[-|–—]\s*/)[0]?.trim();
|
||||
if (cleaned) return cleaned;
|
||||
}
|
||||
|
||||
const h1 = document.querySelector("h1")?.textContent?.trim();
|
||||
return h1 || null;
|
||||
}
|
||||
|
||||
function extractTextFromHtml(html: string): string {
|
||||
const { document } = parseHTML(`<!doctype html><html><body>${html}</body></html>`);
|
||||
for (const selector of ["script", "style", "noscript", "template", "iframe", "svg", "path"]) {
|
||||
for (const el of document.querySelectorAll(selector)) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
return document.body?.textContent?.replace(/\s+/g, " ").trim() ?? "";
|
||||
}
|
||||
|
||||
function parseDocument(html: string): any {
|
||||
const normalized = /<\s*html[\s>]/i.test(html)
|
||||
? html
|
||||
: `<!doctype html><html><body>${html}</body></html>`;
|
||||
return parseHTML(normalized).document;
|
||||
}
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
const { document } = parseHTML(`<div id="__root">${html}</div>`);
|
||||
const root = document.querySelector("#__root");
|
||||
if (!root) return html;
|
||||
|
||||
for (const selector of ["script", "style", "iframe", "noscript", "template", "svg", "path"]) {
|
||||
for (const el of root.querySelectorAll(selector)) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
|
||||
return root.innerHTML;
|
||||
}
|
||||
|
||||
function flattenJsonLdItems(data: unknown): AnyRecord[] {
|
||||
if (!data || typeof data !== "object") return [];
|
||||
if (Array.isArray(data)) return data.flatMap(flattenJsonLdItems);
|
||||
|
||||
const item = data as AnyRecord;
|
||||
if (Array.isArray(item["@graph"])) {
|
||||
return (item["@graph"] as unknown[]).flatMap(flattenJsonLdItems);
|
||||
}
|
||||
|
||||
return [item];
|
||||
}
|
||||
|
||||
function parseJsonLdScripts(document: any): AnyRecord[] {
|
||||
const results: AnyRecord[] = [];
|
||||
const scripts = document.querySelectorAll("script[type='application/ld+json']");
|
||||
|
||||
for (const script of scripts) {
|
||||
try {
|
||||
const data = JSON.parse(script.textContent ?? "");
|
||||
results.push(...flattenJsonLdItems(data));
|
||||
} catch {
|
||||
// ignore malformed blocks
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function isArticleType(item: AnyRecord): boolean {
|
||||
const value = Array.isArray(item["@type"]) ? item["@type"][0] : item["@type"];
|
||||
return typeof value === "string" && ARTICLE_TYPES.has(value);
|
||||
}
|
||||
|
||||
function extractAuthorFromJsonLd(authorData: unknown): string | null {
|
||||
if (typeof authorData === "string") return authorData;
|
||||
if (!authorData || typeof authorData !== "object") return null;
|
||||
|
||||
if (Array.isArray(authorData)) {
|
||||
const names = authorData
|
||||
.map((author) => extractAuthorFromJsonLd(author))
|
||||
.filter((name): name is string => Boolean(name));
|
||||
return names.length > 0 ? names.join(", ") : null;
|
||||
}
|
||||
|
||||
const author = authorData as AnyRecord;
|
||||
return typeof author.name === "string" ? author.name : null;
|
||||
}
|
||||
|
||||
function parseJsonLdItem(item: AnyRecord): ExtractionCandidate | null {
|
||||
if (!isArticleType(item)) return null;
|
||||
|
||||
const rawContent =
|
||||
(typeof item.articleBody === "string" && item.articleBody) ||
|
||||
(typeof item.text === "string" && item.text) ||
|
||||
(typeof item.description === "string" && item.description) ||
|
||||
null;
|
||||
|
||||
if (!rawContent) return null;
|
||||
|
||||
const content = rawContent.trim();
|
||||
const htmlLike = /<\/?[a-z][\s\S]*>/i.test(content);
|
||||
const textContent = htmlLike ? extractTextFromHtml(content) : content;
|
||||
|
||||
if (textContent.length < MIN_CONTENT_LENGTH) return null;
|
||||
|
||||
return {
|
||||
title: pickString(item.headline, item.name),
|
||||
byline: extractAuthorFromJsonLd(item.author),
|
||||
excerpt: pickString(item.description),
|
||||
published: pickString(item.datePublished, item.dateCreated),
|
||||
html: htmlLike ? content : null,
|
||||
textContent,
|
||||
method: "json-ld",
|
||||
const metadata: PageMetadata = {
|
||||
url,
|
||||
title: result.title || "",
|
||||
description: result.description || undefined,
|
||||
author: result.author || undefined,
|
||||
published: result.published || undefined,
|
||||
captured_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function tryJsonLdExtraction(document: any): ExtractionCandidate | null {
|
||||
for (const item of parseJsonLdScripts(document)) {
|
||||
const extracted = parseJsonLdItem(item);
|
||||
if (extracted) return extracted;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getByPath(value: unknown, path: string): unknown {
|
||||
let current = value;
|
||||
for (const part of path.split(".")) {
|
||||
if (!current || typeof current !== "object") return undefined;
|
||||
current = (current as AnyRecord)[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function isContentBlockArray(value: unknown): value is AnyRecord[] {
|
||||
if (!Array.isArray(value) || value.length === 0) return false;
|
||||
return value.slice(0, 5).some((item) => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const obj = item as AnyRecord;
|
||||
return "type" in obj || "text" in obj || "textHtml" in obj || "content" in obj;
|
||||
});
|
||||
}
|
||||
|
||||
function extractTextFromContentBlocks(blocks: AnyRecord[]): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
function pushParagraph(text: string): void {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
parts.push(trimmed, "\n\n");
|
||||
}
|
||||
|
||||
function walk(node: unknown): void {
|
||||
if (!node || typeof node !== "object") return;
|
||||
const block = node as AnyRecord;
|
||||
|
||||
if (typeof block.text === "string") {
|
||||
pushParagraph(block.text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof block.textHtml === "string") {
|
||||
pushParagraph(extractTextFromHtml(block.textHtml));
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(block.items)) {
|
||||
for (const item of block.items) {
|
||||
if (item && typeof item === "object") {
|
||||
const text = pickString((item as AnyRecord).text);
|
||||
if (text) parts.push(`- ${text}\n`);
|
||||
}
|
||||
}
|
||||
parts.push("\n");
|
||||
}
|
||||
|
||||
if (Array.isArray(block.components)) {
|
||||
for (const component of block.components) {
|
||||
walk(component);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(block.content)) {
|
||||
for (const child of block.content) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const block of blocks) {
|
||||
walk(block);
|
||||
}
|
||||
|
||||
return parts.join("").replace(/\n{3,}/g, "\n\n").trim();
|
||||
}
|
||||
|
||||
function tryStringBodyExtraction(
|
||||
content: string,
|
||||
meta: AnyRecord,
|
||||
document: any,
|
||||
method: string
|
||||
): ExtractionCandidate | null {
|
||||
if (!content || content.length < MIN_CONTENT_LENGTH) return null;
|
||||
|
||||
const isHtml = /<\/?[a-z][\s\S]*>/i.test(content);
|
||||
const html = isHtml ? sanitizeHtml(content) : null;
|
||||
const textContent = isHtml ? extractTextFromHtml(html) : content.trim();
|
||||
|
||||
if (textContent.length < MIN_CONTENT_LENGTH) return null;
|
||||
|
||||
return {
|
||||
title: pickString(meta.headline, meta.title, extractTitle(document)),
|
||||
byline: pickString(meta.byline, meta.author),
|
||||
excerpt: pickString(meta.description, meta.excerpt, generateExcerpt(null, textContent)),
|
||||
published: pickString(meta.datePublished, meta.publishedAt, extractPublishedTime(document)),
|
||||
html,
|
||||
textContent,
|
||||
method,
|
||||
};
|
||||
}
|
||||
|
||||
function tryNextDataExtraction(document: any): ExtractionCandidate | null {
|
||||
try {
|
||||
const script = document.querySelector("script#__NEXT_DATA__");
|
||||
if (!script?.textContent) return null;
|
||||
|
||||
const data = JSON.parse(script.textContent) as AnyRecord;
|
||||
const pageProps = (getByPath(data, "props.pageProps") ?? {}) as AnyRecord;
|
||||
|
||||
for (const path of NEXT_DATA_CONTENT_PATHS) {
|
||||
const value = getByPath(data, path);
|
||||
|
||||
if (typeof value === "string") {
|
||||
const parentPath = path.split(".").slice(0, -1).join(".");
|
||||
const parent = (getByPath(data, parentPath) ?? {}) as AnyRecord;
|
||||
const meta = {
|
||||
...pageProps,
|
||||
...parent,
|
||||
title: parent.title ?? (pageProps.title as string | undefined),
|
||||
};
|
||||
|
||||
const candidate = tryStringBodyExtraction(value, meta, document, "next-data");
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
|
||||
if (isContentBlockArray(value)) {
|
||||
const textContent = extractTextFromContentBlocks(value);
|
||||
if (textContent.length < MIN_CONTENT_LENGTH) continue;
|
||||
|
||||
return {
|
||||
title: pickString(
|
||||
getByPath(data, "props.pageProps.content.headline"),
|
||||
getByPath(data, "props.pageProps.article.headline"),
|
||||
getByPath(data, "props.pageProps.article.title"),
|
||||
getByPath(data, "props.pageProps.post.title"),
|
||||
pageProps.title,
|
||||
extractTitle(document)
|
||||
),
|
||||
byline: pickString(
|
||||
getByPath(data, "props.pageProps.author.name"),
|
||||
getByPath(data, "props.pageProps.article.author.name")
|
||||
),
|
||||
excerpt: pickString(
|
||||
getByPath(data, "props.pageProps.content.description"),
|
||||
getByPath(data, "props.pageProps.article.description"),
|
||||
pageProps.description,
|
||||
generateExcerpt(null, textContent)
|
||||
),
|
||||
published: pickString(
|
||||
getByPath(data, "props.pageProps.content.datePublished"),
|
||||
getByPath(data, "props.pageProps.article.datePublished"),
|
||||
getByPath(data, "props.pageProps.publishedAt"),
|
||||
extractPublishedTime(document)
|
||||
),
|
||||
html: null,
|
||||
textContent,
|
||||
method: "next-data",
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildReadabilityCandidate(
|
||||
article: ReturnType<Readability["parse"]>,
|
||||
document: any,
|
||||
method: string
|
||||
): ExtractionCandidate | null {
|
||||
const textContent = article?.textContent?.trim() ?? "";
|
||||
if (textContent.length < MIN_CONTENT_LENGTH) return null;
|
||||
|
||||
return {
|
||||
title: pickString(article?.title, extractTitle(document)),
|
||||
byline: pickString(article?.byline),
|
||||
excerpt: pickString(article?.excerpt, generateExcerpt(null, textContent)),
|
||||
published: pickString(article?.publishedTime, extractPublishedTime(document)),
|
||||
html: article?.content ? sanitizeHtml(article.content) : null,
|
||||
textContent,
|
||||
method,
|
||||
};
|
||||
}
|
||||
|
||||
function tryReadability(document: any): ExtractionCandidate | null {
|
||||
try {
|
||||
const strictClone = document.cloneNode(true) as Document;
|
||||
const strictResult = buildReadabilityCandidate(
|
||||
new Readability(strictClone).parse(),
|
||||
document,
|
||||
"readability"
|
||||
);
|
||||
if (strictResult) return strictResult;
|
||||
|
||||
const relaxedClone = document.cloneNode(true) as Document;
|
||||
return buildReadabilityCandidate(
|
||||
new Readability(relaxedClone, { charThreshold: 120 }).parse(),
|
||||
document,
|
||||
"readability-relaxed"
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function trySelectorExtraction(document: any): ExtractionCandidate | null {
|
||||
for (const selector of CONTENT_SELECTORS) {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) continue;
|
||||
|
||||
const clone = element.cloneNode(true) as Element;
|
||||
for (const removeSelector of REMOVE_SELECTORS) {
|
||||
for (const node of clone.querySelectorAll(removeSelector)) {
|
||||
node.remove();
|
||||
}
|
||||
}
|
||||
|
||||
const html = sanitizeHtml(clone.innerHTML);
|
||||
const textContent = extractTextFromHtml(html);
|
||||
if (textContent.length < MIN_CONTENT_LENGTH) continue;
|
||||
|
||||
return {
|
||||
title: extractTitle(document),
|
||||
byline: null,
|
||||
excerpt: generateExcerpt(null, textContent),
|
||||
published: extractPublishedTime(document),
|
||||
html,
|
||||
textContent,
|
||||
method: `selector:${selector}`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function tryBodyExtraction(document: any): ExtractionCandidate | null {
|
||||
const body = document.body;
|
||||
if (!body) return null;
|
||||
|
||||
const clone = body.cloneNode(true) as Element;
|
||||
for (const removeSelector of REMOVE_SELECTORS) {
|
||||
for (const node of clone.querySelectorAll(removeSelector)) {
|
||||
node.remove();
|
||||
}
|
||||
}
|
||||
|
||||
const html = sanitizeHtml(clone.innerHTML);
|
||||
const textContent = extractTextFromHtml(html);
|
||||
if (!textContent) return null;
|
||||
|
||||
return {
|
||||
title: extractTitle(document),
|
||||
byline: null,
|
||||
excerpt: generateExcerpt(null, textContent),
|
||||
published: extractPublishedTime(document),
|
||||
html,
|
||||
textContent,
|
||||
method: "body-fallback",
|
||||
};
|
||||
}
|
||||
|
||||
function pickBestCandidate(candidates: ExtractionCandidate[]): ExtractionCandidate | null {
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const methodOrder = [
|
||||
"readability",
|
||||
"readability-relaxed",
|
||||
"next-data",
|
||||
"json-ld",
|
||||
"selector:",
|
||||
"body-fallback",
|
||||
];
|
||||
|
||||
function methodRank(method: string): number {
|
||||
const idx = methodOrder.findIndex((entry) =>
|
||||
entry.endsWith(":") ? method.startsWith(entry) : method === entry
|
||||
);
|
||||
return idx === -1 ? methodOrder.length : idx;
|
||||
}
|
||||
|
||||
const ranked = [...candidates].sort((a, b) => {
|
||||
const rankA = methodRank(a.method);
|
||||
const rankB = methodRank(b.method);
|
||||
if (rankA !== rankB) return rankA - rankB;
|
||||
return (b.textContent.length ?? 0) - (a.textContent.length ?? 0);
|
||||
});
|
||||
|
||||
for (const candidate of ranked) {
|
||||
if (candidate.textContent.length >= GOOD_CONTENT_LENGTH) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of ranked) {
|
||||
if (candidate.textContent.length >= MIN_CONTENT_LENGTH) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return ranked[0];
|
||||
}
|
||||
|
||||
function extractFromHtml(html: string): ExtractionCandidate | null {
|
||||
const document = parseDocument(html);
|
||||
|
||||
const readabilityCandidate = tryReadability(document);
|
||||
const nextDataCandidate = tryNextDataExtraction(document);
|
||||
const jsonLdCandidate = tryJsonLdExtraction(document);
|
||||
const selectorCandidate = trySelectorExtraction(document);
|
||||
const bodyCandidate = tryBodyExtraction(document);
|
||||
|
||||
const candidates = [
|
||||
readabilityCandidate,
|
||||
nextDataCandidate,
|
||||
jsonLdCandidate,
|
||||
selectorCandidate,
|
||||
bodyCandidate,
|
||||
].filter((candidate): candidate is ExtractionCandidate => Boolean(candidate));
|
||||
|
||||
const winner = pickBestCandidate(candidates);
|
||||
if (!winner) return null;
|
||||
|
||||
return {
|
||||
...winner,
|
||||
title: winner.title ?? extractTitle(document),
|
||||
published: winner.published ?? extractPublishedTime(document),
|
||||
excerpt: winner.excerpt ?? generateExcerpt(null, winner.textContent),
|
||||
};
|
||||
}
|
||||
|
||||
const turndown = new TurndownService({
|
||||
headingStyle: "atx",
|
||||
hr: "---",
|
||||
bulletListMarker: "-",
|
||||
codeBlockStyle: "fenced",
|
||||
emDelimiter: "*",
|
||||
strongDelimiter: "**",
|
||||
linkStyle: "inlined",
|
||||
});
|
||||
|
||||
turndown.use(gfm);
|
||||
|
||||
turndown.remove(["script", "style", "iframe", "noscript", "template", "svg", "path"]);
|
||||
|
||||
turndown.addRule("collapseFigure", {
|
||||
filter: "figure",
|
||||
replacement(content) {
|
||||
return `\n\n${content.trim()}\n\n`;
|
||||
},
|
||||
});
|
||||
|
||||
turndown.addRule("dropInvisibleAnchors", {
|
||||
filter(node) {
|
||||
return node.nodeName === "A" && !(node as Element).textContent?.trim();
|
||||
},
|
||||
replacement() {
|
||||
return "";
|
||||
},
|
||||
});
|
||||
|
||||
function normalizeMarkdown(markdown: string): string {
|
||||
return markdown
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function convertHtmlToMarkdown(html: string): string {
|
||||
if (!html || !html.trim()) return "";
|
||||
|
||||
try {
|
||||
const sanitized = sanitizeHtml(html);
|
||||
return turndown.turndown(sanitized);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackPlainText(html: string): string {
|
||||
const document = parseDocument(html);
|
||||
for (const selector of ["script", "style", "noscript", "template", "iframe", "svg", "path"]) {
|
||||
for (const el of document.querySelectorAll(selector)) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
const text = document.body?.textContent ?? document.documentElement?.textContent ?? "";
|
||||
return normalizeMarkdown(text.replace(/\s+/g, " "));
|
||||
}
|
||||
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
if (!html || !html.trim()) return "";
|
||||
|
||||
const extracted = extractFromHtml(html);
|
||||
if (!extracted) {
|
||||
return fallbackPlainText(html);
|
||||
}
|
||||
|
||||
let markdown = extracted.html ? convertHtmlToMarkdown(extracted.html) : "";
|
||||
if (!markdown.trim()) {
|
||||
markdown = extracted.textContent;
|
||||
}
|
||||
|
||||
return normalizeMarkdown(markdown);
|
||||
return { metadata, markdown: result.content || "" };
|
||||
}
|
||||
|
||||
function escapeYamlValue(value: string): string {
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
import { CdpConnection, getFreePort, launchChrome, waitForChromeDebugPort, waitForNetworkIdle, waitForPageLoad, autoScroll, evaluateScript, killChrome } from "./cdp.js";
|
||||
import { cleanupAndExtractScript, htmlToMarkdown, createMarkdownDocument, type PageMetadata, type ConversionResult } from "./html-to-markdown.js";
|
||||
import { absolutizeUrlsScript, extractContent, createMarkdownDocument, type ConversionResult } from "./html-to-markdown.js";
|
||||
import { resolveUrlToMarkdownDataDir } from "./paths.js";
|
||||
import { DEFAULT_TIMEOUT_MS, CDP_CONNECT_TIMEOUT_MS, NETWORK_IDLE_TIMEOUT_MS, POST_LOAD_DELAY_MS, SCROLL_STEP_WAIT_MS, SCROLL_MAX_STEPS } from "./constants.js";
|
||||
|
||||
@@ -117,21 +117,11 @@ async function captureUrl(args: Args): Promise<ConversionResult> {
|
||||
}
|
||||
|
||||
console.log("Capturing page content...");
|
||||
const extracted = await evaluateScript<{ title: string; description?: string; author?: string; published?: string; html: string }>(
|
||||
cdp, sessionId, cleanupAndExtractScript, args.timeout
|
||||
const { html } = await evaluateScript<{ html: string }>(
|
||||
cdp, sessionId, absolutizeUrlsScript, args.timeout
|
||||
);
|
||||
|
||||
const metadata: PageMetadata = {
|
||||
url: args.url,
|
||||
title: extracted.title || "",
|
||||
description: extracted.description,
|
||||
author: extracted.author,
|
||||
published: extracted.published,
|
||||
captured_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
const markdown = htmlToMarkdown(extracted.html);
|
||||
return { metadata, markdown };
|
||||
return await extractContent(html, args.url);
|
||||
} finally {
|
||||
if (cdp) {
|
||||
try { await cdp.send("Browser.close", {}, { timeoutMs: 5_000 }); } catch {}
|
||||
|
||||
Reference in New Issue
Block a user