fetch-telegram.mjs 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  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. let 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. // tokenized CDN url; downloaded to public/videos/ after the scrape (see below)
  46. const videoSrc = video ? $el.find("video").first().attr("src") || "" : "";
  47. // video posts carry a full-size preview frame in .tgme_widget_message_video_thumb
  48. if (video && !photo) {
  49. const thumbStyle = $el.find(".tgme_widget_message_video_thumb").first().attr("style") || "";
  50. photo = thumbStyle.match(/url\('([^']+)'\)/)?.[1] || "";
  51. }
  52. const link = $el.find(".tgme_widget_message_date").first().attr("href") || `https://t.me/${CHANNEL}/${postId}`;
  53. if (text || photo || video) {
  54. posts.push({ id: Number(postId), text, datetime, photo, video, videoSrc, link });
  55. }
  56. });
  57. console.log(`page ${page}: +${newCount} posts (total kept ${posts.length})`);
  58. if (newCount === 0 || !oldestId) break;
  59. before = oldestId;
  60. await sleep(300);
  61. }
  62. // newest first
  63. posts.sort((a, b) => b.id - a.id);
  64. // telesco.pe video tokens expire within hours — persist the mp4s locally
  65. await mkdir("public/videos", { recursive: true });
  66. for (const post of posts) {
  67. if (!post.videoSrc) continue;
  68. const res = await fetch(post.videoSrc, { headers: { "User-Agent": UA } });
  69. if (!res.ok) {
  70. console.log(`video ${post.id}: HTTP ${res.status}, keeping poster only`);
  71. post.videoSrc = "";
  72. continue;
  73. }
  74. const buf = Buffer.from(await res.arrayBuffer());
  75. const file = `tg-${post.id}.mp4`;
  76. await writeFile(path.join("public/videos", file), buf);
  77. post.videoSrc = `/videos/${file}`;
  78. console.log(`video ${post.id}: saved ${(buf.length / 1e6).toFixed(1)} MB`);
  79. await sleep(300);
  80. }
  81. await mkdir(path.dirname("src/data/telegram-news.json"), { recursive: true });
  82. await writeFile("src/data/telegram-news.json", JSON.stringify(posts, null, 2));
  83. console.log(`saved ${posts.length} posts`);
  84. // Channel avatar for the widget header
  85. const avatar = await fetch(`https://t.me/i/userpic/320/${CHANNEL}.jpg`, { headers: { "User-Agent": UA } });
  86. if (avatar.ok) {
  87. const buf = Buffer.from(await avatar.arrayBuffer());
  88. if (buf.length > 500) {
  89. await writeFile("public/images/telegram-avatar.jpg", buf);
  90. console.log("avatar saved");
  91. } else {
  92. console.log("avatar looks empty, skipped");
  93. }
  94. } else {
  95. console.log(`avatar fetch failed: HTTP ${avatar.status}`);
  96. }