Files

213 lines
7.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'fs/promises';
import { createWriteStream, existsSync, mkdirSync } from 'fs';
import path from 'path';
import puppeteer from 'puppeteer-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import TurndownService from 'turndown';
import https from 'https';
puppeteer.use(StealthPlugin());
const turndownService = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced'
});
// Remove unnecessary elements from Medium articles
turndownService.addRule('remove_unwanted', {
filter: ['script', 'noscript', 'style', 'button', 'svg'],
replacement: () => ''
});
const IMAGES_DIR = path.join(process.cwd(), 'public', 'blog', 'images');
const CONTENT_DIR = path.join(process.cwd(), 'content', 'blog');
async function ensureDirs() {
if (!existsSync(IMAGES_DIR)) {
await fs.mkdir(IMAGES_DIR, { recursive: true });
}
if (!existsSync(CONTENT_DIR)) {
await fs.mkdir(CONTENT_DIR, { recursive: true });
}
}
async function downloadImage(url, filename) {
const destPath = path.join(IMAGES_DIR, filename);
return new Promise((resolve) => {
https.get(url, (res) => {
if (res.statusCode !== 200) {
return resolve(url);
}
const file = createWriteStream(destPath);
res.pipe(file);
file.on('finish', () => {
file.close();
resolve(`/blog/images/${filename}`);
});
file.on('error', () => {
resolve(url);
});
}).on('error', () => {
resolve(url);
});
});
}
function cleanSlug(str) {
let decoded = decodeURIComponent(str);
// Remove the medium id suffix at the end e.g. -b3719c7aaacd
decoded = decoded.replace(/-[a-f0-9]{8,12}$/i, '');
return decoded.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
async function main() {
await ensureDirs();
// Clear existing content/blog directory as requested by the user
const existingFiles = await fs.readdir(CONTENT_DIR);
for (const file of existingFiles) {
if (file.endsWith('.md')) {
await fs.unlink(path.join(CONTENT_DIR, file));
}
}
const fileContent = await fs.readFile(path.join(process.cwd(), 'docs/mediumLinks.md'), 'utf8');
const urls = fileContent.split('\n').map(l => l.trim()).filter(l => l.length > 0);
console.log(`Processing ${urls.length} articles...`);
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
await page.setViewport({ width: 1366, height: 768 });
for (let i = 0; i < urls.length; i++) {
const url = urls[i];
console.log(`[${i + 1}/${urls.length}] Fetching ${url}`);
try {
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
const pageTitle = await page.title();
if (pageTitle.includes('Just a moment')) {
console.log(' -> Waiting for Cloudflare...');
await new Promise(r => setTimeout(r, 7000));
}
await page.evaluate(() => window.scrollBy(0, document.body.scrollHeight));
await new Promise(r => setTimeout(r, 1000));
const meta = await page.evaluate(() => {
const title = document.querySelector('h1')?.innerText || document.querySelector('meta[property="og:title"]')?.content || 'Unknown Title';
const rawExcerpt = document.querySelector('meta[name="description"]')?.content || '';
const coverImage = document.querySelector('meta[property="og:image"]')?.content || '';
const canonical = document.querySelector('link[rel="canonical"]')?.href || '';
const dateRaw = document.querySelector('meta[property="article:published_time"]')?.content || new Date().toISOString();
const readTimeText = document.body.innerText.match(/\d+\s*min\s*read/i);
const readTime = readTimeText ? readTimeText[0] : '5 min read';
// Attempting to just grab the article element, without the author and social toolbars
// Medium usually surrounds the actual article content in section tags
let htmlContext = '';
const sections = Array.from(document.querySelectorAll('article section'));
if(sections.length > 0) {
sections.forEach(s => { htmlContext += s.innerHTML; });
} else {
htmlContext = document.querySelector('article')?.innerHTML || '';
}
return { title, rawExcerpt, coverImage, canonical, dateRaw, readTime, htmlContext };
});
if (!meta.htmlContext) {
console.log(` -> Failed to find article content! Skipping.`);
continue;
}
// Convert HTML back to markdown
let markdown = turndownService.turndown(meta.htmlContext);
// Clean metadata
const rawSlug = url.split('/').pop().split('?')[0];
const slug = cleanSlug(rawSlug);
const date = meta.dateRaw.split('T')[0];
const category = "General"; // Defaulting as discussed
// Safely escape quotes in title/excerpt
const safeTitle = meta.title.replace(/"/g, '\\"');
const safeExcerpt = meta.rawExcerpt.replace(/"/g, '\\"');
// Setup frontmatter
const coverImageDest = `${slug}-cover.jpg`;
const localCoverImageUrl = await downloadImage(meta.coverImage, coverImageDest);
let frontmatter = `---
title: "${safeTitle}"
category: "${category}"
date: "${date}"
readTime: "${meta.readTime}"
author: "Poyraz Avsever"
slug: "${slug}"
excerpt: "${safeExcerpt}"
coverImage: "${localCoverImageUrl}"
canonicalUrl: "${url}"
lang: "tr"
---
`;
// Download content images
const imgRegex = /!\[(.*?)\]\((https:\/\/(?:miro\.medium\.com|cdn-images-\d+\.medium\.com)[^)]+)\)/g;
const promises = [];
const imageMap = {};
let match;
let imgId = 1;
while ((match = imgRegex.exec(markdown)) !== null) {
const originalUrl = match[2];
const filename = `${slug}-img-${imgId++}.jpg`;
promises.push(
downloadImage(originalUrl, filename).then(localPath => {
imageMap[originalUrl] = localPath;
})
);
}
await Promise.all(promises);
// Replace urls in markdown
Object.keys(imageMap).forEach(orig => {
markdown = markdown.split(orig).join(imageMap[orig]);
});
// Medium adds a lot of "Follow me on Medium" user-bars in markdown output. We can keep it but remove the generic leading user snippet
// Cleanup UI noise
markdown = markdown.replace(/\[\s*!\[Poyraz Avsever\].*?\]\(.*?\)\s*\[Poyraz Avsever\].*?\s*\d+ min read\s*·\s*[A-Z][a-z]+\s+\d+,\s+\d+/gs, '');
markdown = markdown.replace(/\[\s*\]\(\/m\/signin\?.*?\)/gs, '');
markdown = markdown.replace(/Press enter or click to view image in full size/g, '');
markdown = markdown.replace(/## Get Poyraz Avsevers stories in.*?Remember me for faster sign in/gs, '');
markdown = markdown.replace(/\n{4,}/g, '\n\n\n');
const finalMd = frontmatter + markdown;
const mdPath = path.join(CONTENT_DIR, `${slug}.md`);
await fs.writeFile(mdPath, finalMd, 'utf8');
console.log(` -> Saved and Cleaned to ${mdPath}`);
} catch (err) {
console.error(` -> Error processing ${url}:`, err.message);
}
}
await browser.close();
console.log('All articles processed!');
}
main();