DEV Community

Monkey.D.Scraping
Monkey.D.Scraping

Posted on Fully Autonomous

Compare two public Telegram channels in n8n and export an HTML brief

When I compare two Telegram channels, I want to open one file, see what each channel published, and jump straight to the original posts. I built an n8n workflow for that and ran it against @telegram and @durov on November 10, 2026. My 30-day run returned two posts for @durov and zero for @telegram. That empty column turned out to be a useful test. A report should show what came back for each source, even when the count is zero.

Here's a minimal version you can build in four nodes. The collection step uses the Telegram Channel Scraper on Apify, which I maintain. The report code takes plain JSON, so you can also try it with the sample below.

1. Fetch a small window of posts

Add Manual Trigger, then an HTTP Request node. Configure a POST to:

https://api.apify.com/v2/acts/monkey.d.scraping~telegram-channel-scraper/run-sync-get-dataset-items
Enter fullscreen mode Exit fullscreen mode

Under Authentication, select Generic Credential Type → Header Auth. Create a credential with name Authorization and value Bearer followed by your Apify API token. Store the token in the credential.

Set query parameters maxTotalChargeUsd=0.01, timeout=90, and clean=true. Under Options, set the HTTP timeout to 110000 milliseconds. Set the response format to JSON and enable Include Response Headers and Status so the next node receives the entire array as body. Send the following JSON body:

{
  "channels": ["telegram", "durov"],
  "afterDate": "30 days",
  "maxMessagesPerChannel": 15,
  "maxMessages": 30,
  "outputPreset": "compact",
  "downloadMedia": false
}
Enter fullscreen mode Exit fullscreen mode

If you want to test without credentials first, connect Manual Trigger to an Edit Fields node instead. Set its mode to JSON Output and paste this fictional response. The demo path does not call Apify.

{
  "body": [
    {
      "channelUsername": "durov",
      "messageUrl": "https://t.me/durov/999999999",
      "publishedAt": "2026-11-10T08:00:00Z",
      "views": 1200,
      "reactionsTotal": 38,
      "text": "Fictional announcement for the demo."
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

2. Rank the posts and generate HTML

Add a Code node in Run Once for All Items mode. Update channels here when you change the request. The demo sample uses a placeholder URL, so set demoMode to false only when using the real HTTP response.

const channels = ['telegram', 'durov'];
const demoMode = true;
const rows = $input.first().json.body;

if (!Array.isArray(rows)) {
  throw new Error('Expected a JSON array in body.');
}

const escapeHtml = (value) => String(value ?? '').replace(
  /[&<>"']/g,
  (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char],
);
const views = (post) => Number.isFinite(post.views) ? post.views : -1;
const metric = (value) => Number.isFinite(value) ? value.toLocaleString('en-US') : '—';
const groups = new Map(channels.map((channel) => [channel, []]));
const seen = new Set();

for (const post of rows) {
  const channel = String(post?.channelUsername ?? '').toLowerCase();
  const match = /^https:\/\/t\.me\/([a-z0-9_]+)\/(\d+)$/i.exec(post?.messageUrl ?? '');
  if (!groups.has(channel) || match?.[1].toLowerCase() !== channel) continue;
  const url = 'https://t.me/' + channel + '/' + match[2];
  if (seen.has(url)) continue;
  seen.add(url);
  groups.get(channel).push({ ...post, url });
}

const sections = [...groups].map(([channel, posts]) => {
  posts.sort((a, b) => views(b) - views(a) ||
    (Date.parse(b.publishedAt) || 0) - (Date.parse(a.publishedAt) || 0));
  const entries = posts.slice(0, 3).map((post) => {
    const excerpt = String(post.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 200);
    const date = post.publishedAt?.slice(0, 10) ?? 'Unknown date';
    const source = demoMode ? '' : ' · <a href="' + post.url + '">Source</a>';
    return '<li><p>' + escapeHtml(excerpt) + '</p><small>' +
      escapeHtml(date) + ' · ' + metric(post.views) + ' views · ' +
      metric(post.reactionsTotal) + ' reactions' + source + '</small></li>';
  });
  const list = entries.length ? '<ol>' + entries.join('') + '</ol>' : '<p>No posts returned.</p>';
  return '<section><h2>@' + escapeHtml(channel) + '</h2><p>' +
    posts.length + ' posts returned</p>' + list + '</section>';
});

const reportHtml = '<!doctype html><html lang="en"><meta charset="utf-8">' +
  '<meta name="viewport" content="width=device-width, initial-scale=1">' +
  '<title>Telegram channel brief</title><style>' +
  'body{font:16px system-ui,sans-serif;margin:2rem auto;padding:0 1rem;max-width:58rem}' +
  'main{display:grid;gap:1rem;grid-template-columns:repeat(auto-fit,minmax(min(100%,22rem),1fr))}' +
  'section{border:1px solid #ccd;padding:1rem;border-radius:.75rem}li{margin-bottom:1rem}' +
  '</style><body><h1>Telegram channel brief</h1>' +
  (demoMode ? '<p>Demo data</p>' : '') + '<main>' + sections.join('') + '</main></body></html>';

return [{ json: { reportHtml } }];
Enter fullscreen mode Exit fullscreen mode

Escaping the excerpts matters because the report opens in a browser. The code also constructs links from validated Telegram channel names and numeric post IDs. Views and reactions stay blank when the source does not expose them.

3. Download the brief

Connect a Convert to File node, choose Convert to Text File, set Text Input Field to reportHtml, and set File Name to telegram-channel-briefing.html. Execute the workflow, then open the final node and select Output → Binary → Download.

The report ranks up to three posts from each channel's returned sample. A post published yesterday has had less time to accumulate views than one published three weeks ago. Keep the dates visible when you compare the two lists. If you see zero rows for a source, check the channel and date window before drawing conclusions about its activity.

I used AI assistance to draft and edit this article.

Top comments (0)