fetch-telegram.mjs 3.1 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
  1. // Fetches the public Telegram channel feed (t.me/s/...) — the whole first page
  2. // chain via ?before=<id> pagination — and saves the posts for the home-page
  3. // Telegram widget. Run manually: npm run fetch-news
  4. import * as cheerio from "cheerio";
  5. import { mkdir, writeFile } from "node:fs/promises";
  6. import path from "node:path";
  7. const CHANNEL = "scientia_ru";
  8. const MAX_PAGES = 40; // safety cap (~20 posts per page)
  9. const UA =
  10. "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
  11. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  12. const posts = [];
  13. const seen = new Set();
  14. let before = null;
  15. for (let page = 0; page < MAX_PAGES; page++) {
  16. const url = `https://t.me/s/${CHANNEL}${before ? `?before=${before}` : ""}`;
  17. const res = await fetch(url, { headers: { "User-Agent": UA } });
  18. if (!res.ok) {
  19. console.error(`page ${page}: HTTP ${res.status}`);
  20. break;
  21. }
  22. const $ = cheerio.load(await res.text());
  23. let newCount = 0;
  24. let oldestId = null;
  25. $(".tgme_widget_message").each((_, el) => {
  26. const $el = $(el);
  27. const postId = ($el.attr("data-post") || "").split("/")[1];
  28. if (!postId || seen.has(postId)) return;
  29. seen.add(postId);
  30. newCount++;
  31. oldestId = postId;
  32. const textNode = $el.find(".tgme_widget_message_text").first();
  33. // Keep basic formatting tags, drop telegram-internal attributes
  34. textNode.find("a, b, i, u, s, strong, em, code, br, blockquote").each((__, keep) => {
  35. const attrs = $(keep).attr() || {};
  36. for (const name of Object.keys(attrs)) {
  37. if (name !== "href") $(keep).removeAttr(name);
  38. }
  39. });
  40. const text = textNode.html()?.trim() || "";
  41. const datetime = $el.find("time").first().attr("datetime") || "";
  42. const photoStyle = $el.find(".tgme_widget_message_photo_wrap").first().attr("style") || "";
  43. const photo = photoStyle.match(/url\('([^']+)'\)/)?.[1]?.split("?")[0] || "";
  44. const video = $el.find(".tgme_widget_message_video_wrap, .tgme_widget_message_video_player").length > 0;
  45. const link = $el.find(".tgme_widget_message_date").first().attr("href") || `https://t.me/${CHANNEL}/${postId}`;
  46. if (text || photo || video) {
  47. posts.push({ id: Number(postId), text, datetime, photo, video, link });
  48. }
  49. });
  50. console.log(`page ${page}: +${newCount} posts (total kept ${posts.length})`);
  51. if (newCount === 0 || !oldestId) break;
  52. before = oldestId;
  53. await sleep(300);
  54. }
  55. // newest first
  56. posts.sort((a, b) => b.id - a.id);
  57. await mkdir(path.dirname("src/data/telegram-news.json"), { recursive: true });
  58. await writeFile("src/data/telegram-news.json", JSON.stringify(posts, null, 2));
  59. console.log(`saved ${posts.length} posts`);
  60. // Channel avatar for the widget header
  61. const avatar = await fetch(`https://t.me/i/userpic/320/${CHANNEL}.jpg`, { headers: { "User-Agent": UA } });
  62. if (avatar.ok) {
  63. const buf = Buffer.from(await avatar.arrayBuffer());
  64. if (buf.length > 500) {
  65. await writeFile("public/images/telegram-avatar.jpg", buf);
  66. console.log("avatar saved");
  67. } else {
  68. console.log("avatar looks empty, skipped");
  69. }
  70. } else {
  71. console.log(`avatar fetch failed: HTTP ${avatar.status}`);
  72. }