VISHAL18for4 commited on
Commit
a11ad47
Β·
verified Β·
1 Parent(s): 86fb95e

Upload 2 files

Browse files
Files changed (2) hide show
  1. chatbot.py +212 -0
  2. data_fetcher.py +270 -0
chatbot.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ chatbot.py β€” Answers naturally from what the AI has learned.
3
+ Filters out junk, synthesizes real paragraph answers.
4
+ """
5
+ import json, re, math, os, random
6
+ from collections import Counter, defaultdict
7
+ from html import unescape
8
+
9
+ KNOWLEDGE_FILE = 'knowledge.jsonl'
10
+
11
+ STOPWORDS = {
12
+ 'a','an','the','is','it','in','on','at','to','for','of','and','or','but',
13
+ 'was','are','were','be','been','have','has','had','do','does','did','will',
14
+ 'would','could','should','may','might','that','this','these','those','with',
15
+ 'from','by','as','not','also','than','then','so','if','when','what','how',
16
+ 'who','which','its','their','our','your','my','his','her','we','they','he',
17
+ 'she','you','i','me','him','us','them','said','says',
18
+ }
19
+
20
+ # Sentences that are website UI / taglines β€” not real knowledge
21
+ JUNK = re.compile(
22
+ r'(sign up|subscribe|click here|breaking news:|stay informed|'
23
+ r'all rights reserved|privacy policy|cookie policy|advertisement|'
24
+ r'follow us|share this|read more|learn more|'
25
+ r'has everything you need|stay up to date|get the latest|'
26
+ r'news today ap|ap news|everything you need to know for|'
27
+ r'top breaking|live science\.|sciencedaily|newscientist|'
28
+ r'latest \w+ news articles today)',
29
+ re.IGNORECASE
30
+ )
31
+
32
+ def tokenize(text):
33
+ text = re.sub(r'[^\w\s]', ' ', text.lower())
34
+ return [w for w in text.split() if w not in STOPWORDS and len(w) > 2]
35
+
36
+ def scrub(text):
37
+ """Clean HTML artifacts from stored text."""
38
+ text = unescape(str(text))
39
+ text = re.sub(r'&#?[a-zA-Z0-9]+;', ' ', text)
40
+ text = re.sub(r'<[^>]+>', ' ', text)
41
+ text = re.sub(r'\s+', ' ', text).strip()
42
+ return text
43
+
44
+ def is_junk(sentence):
45
+ if len(sentence.split()) < 7: return True
46
+ if JUNK.search(sentence): return True
47
+ return False
48
+
49
+
50
+ class KnowledgeBase:
51
+ def __init__(self):
52
+ self.docs=[]; self.doc_tokens=[]; self.idf={}; self.last_count=0
53
+ self._load()
54
+
55
+ def _load(self):
56
+ if not os.path.exists(KNOWLEDGE_FILE): return
57
+ new_docs = []
58
+ try:
59
+ with open(KNOWLEDGE_FILE,'r',encoding='utf-8') as f:
60
+ for line in f:
61
+ l=line.strip()
62
+ if l:
63
+ try: new_docs.append(json.loads(l))
64
+ except: pass
65
+ except: return
66
+ if len(new_docs)==self.last_count: return
67
+ self.docs=new_docs; self.last_count=len(new_docs)
68
+ self.doc_tokens=[tokenize(scrub(d.get('text',''))) for d in self.docs]
69
+ N=len(self.docs)
70
+ df=defaultdict(int)
71
+ for tok in self.doc_tokens:
72
+ for w in set(tok): df[w]+=1
73
+ self.idf={w:math.log((N+1)/(c+1))+1 for w,c in df.items()}
74
+
75
+ def search(self, query, top_k=10):
76
+ self._load()
77
+ if not self.docs: return []
78
+ q=tokenize(query)
79
+ if not q: return []
80
+ def sc(dt):
81
+ tf=Counter(dt); n=max(len(dt),1)
82
+ return sum((tf[w]/n)*self.idf.get(w,0) for w in q if w in tf)
83
+ ranked=sorted(range(len(self.docs)),key=lambda i:-sc(self.doc_tokens[i]))
84
+ return [(self.docs[i], sc(self.doc_tokens[i]))
85
+ for i in ranked[:top_k] if sc(self.doc_tokens[i])>0]
86
+
87
+ def get_stats(self):
88
+ self._load()
89
+ return {
90
+ 'total': len(self.docs),
91
+ 'categories': dict(Counter(d.get('category','other') for d in self.docs)),
92
+ 'sources': dict(Counter(d.get('source','unknown') for d in self.docs)),
93
+ }
94
+
95
+
96
+ def build_answer(query, results):
97
+ """
98
+ Pull the best real sentences from retrieved docs and write a natural answer.
99
+ Skips junk/tagline sentences. Returns None if nothing good found.
100
+ """
101
+ q_tokens = set(tokenize(query))
102
+ candidates = []
103
+
104
+ for doc, relevance in results:
105
+ text = scrub(doc.get('text',''))
106
+ # Split into sentences
107
+ sents = re.split(r'(?<=[.!?])\s+', text)
108
+ for s in sents:
109
+ s = s.strip()
110
+ if is_junk(s): continue
111
+ overlap = len(q_tokens & set(tokenize(s)))
112
+ # Score = topic overlap + doc relevance
113
+ candidates.append((overlap * 2 + relevance, s))
114
+
115
+ if not candidates:
116
+ return None
117
+
118
+ # Deduplicate, pick top unique sentences
119
+ seen = set(); good = []
120
+ for score, sent in sorted(candidates, reverse=True):
121
+ key = ' '.join(sent.lower().split()[:6])
122
+ if key not in seen:
123
+ seen.add(key)
124
+ good.append(sent)
125
+ if len(good) >= 5: break
126
+
127
+ if not good:
128
+ return None
129
+
130
+ # Write as flowing paragraphs (not bullets)
131
+ if len(good) == 1:
132
+ return good[0]
133
+ elif len(good) <= 3:
134
+ return ' '.join(good)
135
+ else:
136
+ # Split into two paragraphs
137
+ return ' '.join(good[:2]) + '\n\n' + ' '.join(good[2:])
138
+
139
+
140
+ class RAGChatbot:
141
+ def __init__(self):
142
+ self.kb = KnowledgeBase()
143
+
144
+ def _get_reply(self, query, results, stats):
145
+ total = stats['total']
146
+
147
+ if total == 0:
148
+ return ("My brain is empty right now 🧠\n\n"
149
+ "The app is already auto-training β€” just wait a minute "
150
+ "while I read some articles, then ask again!")
151
+
152
+ answer = build_answer(query, results) if results else None
153
+
154
+ if not answer:
155
+ topics = ', '.join(list(stats['categories'].keys())[:5])
156
+ return (f"I've read **{total} articles** but haven't learned enough "
157
+ f"about *\"{query}\"* yet.\n\n"
158
+ f"I know most about: **{topics}**.\n\n"
159
+ f"Keep me running and I'll learn more!")
160
+
161
+ return answer
162
+
163
+ def chat(self, user_message, history):
164
+ history = list(history or [])
165
+ msg = user_message.strip()
166
+ if not msg: return history
167
+
168
+ lower = msg.lower()
169
+ stats = self.kb.get_stats()
170
+
171
+ if lower in ('hi','hello','hey','sup','yo','hiya','howdy','helo','hai'):
172
+ total = stats['total']
173
+ if total == 0:
174
+ bot_reply = "Hey! πŸ‘‹ I'm just starting up β€” give me a minute to read some articles and I'll be ready to answer questions!"
175
+ else:
176
+ topics = ', '.join(list(stats['categories'].keys())[:4])
177
+ bot_reply = f"Hey! πŸ‘‹ I've read **{total} articles** so far. I know quite a bit about **{topics}**. What do you want to know?"
178
+
179
+ elif any(p in lower for p in ('how are you',"what's up",'whats up')):
180
+ bot_reply = "I'm doing great, always learning! πŸ€– What do you want to know?"
181
+
182
+ elif any(p in lower for p in ('who are you','what are you','what can you do')):
183
+ bot_reply = (
184
+ "I'm a **Living Neural Network** β€” an AI that only knows what it has actually read. 🧠\n\n"
185
+ f"So far I've read **{stats['total']} articles** from the internet. "
186
+ "I don't have any pre-built knowledge β€” everything I know came from real articles I've been fed. "
187
+ "The more I train, the smarter I get. Ask me anything!"
188
+ )
189
+
190
+ elif lower in ('stats','status','knowledge'):
191
+ lines = '\n'.join(f"β€’ **{k}**: {v} articles"
192
+ for k,v in sorted(stats['categories'].items(), key=lambda x:-x[1]))
193
+ srcs = ', '.join(f"{k} ({v})" for k,v in list(stats['sources'].items())[:6])
194
+ bot_reply = (f"**What I've learned:**\n\n{lines or 'β€’ nothing yet'}\n\n"
195
+ f"**Sources:** {srcs or 'none yet'}\n"
196
+ f"**Total:** {stats['total']} articles")
197
+
198
+ elif lower in ('help','?'):
199
+ bot_reply = ("Just ask me anything β€” I'll answer from what I've actually read!\n\n"
200
+ "Try: *What is science?* Β· *Who is Elon Musk?* Β· *What's happening in AI?*\n\n"
201
+ "Type `stats` to see what I know.")
202
+
203
+ elif any(p in lower for p in ('thanks','thank you','thx','ty')):
204
+ bot_reply = "You're welcome! 😊"
205
+
206
+ else:
207
+ results = self.kb.search(msg, top_k=10)
208
+ bot_reply = self._get_reply(msg, results, stats)
209
+
210
+ history.append({"role": "user", "content": user_message})
211
+ history.append({"role": "assistant", "content": bot_reply})
212
+ return history
data_fetcher.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ data_fetcher.py β€” Fetches REAL article content (not website taglines).
3
+ Key fix: extracts full paragraph text from pages, filters out metadata garbage.
4
+ """
5
+ import requests, feedparser, random, re, time, json
6
+ from datetime import datetime
7
+ from collections import deque
8
+ from urllib.parse import quote_plus
9
+ from html import unescape
10
+
11
+ try:
12
+ from bs4 import BeautifulSoup
13
+ BS4 = True
14
+ except ImportError:
15
+ BS4 = False
16
+
17
+ HEADERS = {
18
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
19
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
20
+ 'Accept-Language': 'en-US,en;q=0.9',
21
+ }
22
+
23
+ # ── GARBAGE PATTERNS β€” these are website UI text, not real content ─────────────
24
+ GARBAGE_PATTERNS = re.compile(
25
+ r'(sign up|subscribe|click here|breaking news|stay informed|'
26
+ r'all rights reserved|privacy policy|cookie|advertisement|'
27
+ r'newsletter|follow us|share this|read more|learn more|'
28
+ r'top stories|latest news from|everything you need to know|'
29
+ r'has everything|stay up to date|get the latest|'
30
+ r'news articles today|news today ap|ap news|'
31
+ r'live science|science news,|sciencedaily|'
32
+ r'^\s*\d+\s*$|^[\w\s]{1,15}:?\s*$)', # very short / numeric only
33
+ re.IGNORECASE
34
+ )
35
+
36
+ RSS_FEEDS = {
37
+ 'technology': [
38
+ 'https://feeds.arstechnica.com/arstechnica/index',
39
+ 'https://www.wired.com/feed/rss',
40
+ 'https://hnrss.org/frontpage',
41
+ 'https://www.theverge.com/rss/index.xml',
42
+ 'https://dev.to/feed',
43
+ 'https://thenextweb.com/feed/',
44
+ ],
45
+ 'science': [
46
+ 'https://www.sciencedaily.com/rss/all.xml',
47
+ 'https://rss.nytimes.com/services/xml/rss/nyt/Science.xml',
48
+ 'http://export.arxiv.org/rss/cs.AI',
49
+ 'https://phys.org/rss-feed/breaking/',
50
+ ],
51
+ 'world': [
52
+ 'https://feeds.bbci.co.uk/news/world/rss.xml',
53
+ 'https://rss.nytimes.com/services/xml/rss/nyt/World.xml',
54
+ 'https://www.aljazeera.com/xml/rss/all.xml',
55
+ 'https://feeds.npr.org/1004/rss.xml',
56
+ 'https://www.theguardian.com/world/rss',
57
+ ],
58
+ 'sports': ['https://feeds.bbci.co.uk/sport/rss.xml',
59
+ 'https://rss.nytimes.com/services/xml/rss/nyt/Sports.xml'],
60
+ 'business': ['https://feeds.bbci.co.uk/news/business/rss.xml',
61
+ 'https://rss.nytimes.com/services/xml/rss/nyt/Business.xml',
62
+ 'https://www.theguardian.com/business/rss'],
63
+ 'health': ['https://feeds.bbci.co.uk/news/health/rss.xml',
64
+ 'https://rss.nytimes.com/services/xml/rss/nyt/Health.xml'],
65
+ 'entertainment': ['https://feeds.bbci.co.uk/news/entertainment_and_arts/rss.xml',
66
+ 'https://variety.com/feed/'],
67
+ 'ai': ['http://export.arxiv.org/rss/cs.AI',
68
+ 'http://export.arxiv.org/rss/cs.LG',
69
+ 'https://hnrss.org/frontpage'],
70
+ }
71
+
72
+ REDDIT_SUBS = [
73
+ ('technology', 'technology'),
74
+ ('science', 'science'),
75
+ ('world', 'worldnews'),
76
+ ('sports', 'sports'),
77
+ ('business', 'business'),
78
+ ('health', 'Health'),
79
+ ('entertainment', 'movies'),
80
+ ('ai', 'MachineLearning'),
81
+ ('ai', 'artificial'),
82
+ ('technology', 'programming'),
83
+ ]
84
+
85
+ WIKI_TOPICS = [
86
+ ('science', ['Physics','Chemistry','Biology','Astronomy','Mathematics','Evolution','Genetics']),
87
+ ('technology', ['Artificial intelligence','Machine learning','Computer science','Internet','Robotics']),
88
+ ('world', ['Climate change','Democracy','United Nations','Geopolitics','Economics']),
89
+ ('health', ['Medicine','Vaccine','Cancer','Nutrition','Mental health','COVID-19']),
90
+ ('ai', ['Neural network','Deep learning','Natural language processing','GPT','Transformer model']),
91
+ ('business', ['Stock market','Cryptocurrency','Inflation','Supply chain','Startup']),
92
+ ('entertainment',['Film','Music','Video game','Television','Streaming']),
93
+ ('sports', ['Football','Basketball','Olympic Games','Tennis','Cricket']),
94
+ ]
95
+
96
+ HN_TOP = 'https://hacker-news.firebaseio.com/v0/topstories.json'
97
+ HN_ITEM = 'https://hacker-news.firebaseio.com/v0/item/{}.json'
98
+
99
+ # ── UTILS ────────────────────────────────────────────────────────────────────
100
+ def clean(text, max_chars=800):
101
+ if not text: return ''
102
+ text = unescape(str(text))
103
+ text = re.sub(r'&#?[a-zA-Z0-9]+;', ' ', text)
104
+ text = re.sub(r'<[^>]+>', ' ', text)
105
+ text = re.sub(r'http\S+', '', text)
106
+ text = re.sub(r'[^\w\s.,!?;:\'\-–—]', ' ', text)
107
+ text = re.sub(r'\s+', ' ', text).strip()
108
+ return text[:max_chars]
109
+
110
+ def is_garbage(text):
111
+ """Return True if text is a website tagline/nav text, not real content."""
112
+ if len(text) < 40: return True
113
+ if GARBAGE_PATTERNS.search(text): return True
114
+ # If it's just a title with no real sentence, skip
115
+ words = text.split()
116
+ if len(words) < 8: return True
117
+ return False
118
+
119
+ def make_item(text, category, source, extra=None):
120
+ text = clean(text)
121
+ if not text or is_garbage(text): return None
122
+ return {'text': text, 'category': category, 'source': source,
123
+ 'timestamp': datetime.utcnow().isoformat(), **(extra or {})}
124
+
125
+ def extract_article_text(url, max_chars=600):
126
+ """Fetch a URL and extract real paragraph text using BeautifulSoup."""
127
+ if not BS4: return ''
128
+ try:
129
+ r = requests.get(url, headers=HEADERS, timeout=8)
130
+ soup = BeautifulSoup(r.text, 'html.parser')
131
+ # Remove nav, header, footer, ads
132
+ for tag in soup(['nav','header','footer','script','style','aside',
133
+ 'figure','form','button','iframe']):
134
+ tag.decompose()
135
+ # Get paragraphs
136
+ paras = soup.find_all('p')
137
+ text = ' '.join(p.get_text(' ', strip=True) for p in paras if len(p.get_text()) > 60)
138
+ return clean(text, max_chars)
139
+ except Exception:
140
+ return ''
141
+
142
+ # ── FETCHERS ─────────────────────────────────────────────────────────────────
143
+ def fetch_rss(category, url):
144
+ items = []
145
+ try:
146
+ feed = feedparser.parse(url)
147
+ for entry in feed.entries[:12]:
148
+ title = entry.get('title', '')
149
+ summary = entry.get('summary', entry.get('description', ''))
150
+ # Prefer summary if it has real content (>100 chars)
151
+ body = summary if len(summary) > 100 else ''
152
+ text = f"{title}. {body}".strip()
153
+ item = make_item(text, category, 'rss', {'feed': url.split('/')[2]})
154
+ if item: items.append(item)
155
+ except Exception: pass
156
+ return items
157
+
158
+ def fetch_reddit(category, subreddit):
159
+ items = []
160
+ try:
161
+ url = f"https://www.reddit.com/r/{subreddit}/top.json?limit=20&t=day"
162
+ r = requests.get(url, headers=HEADERS, timeout=10)
163
+ r.raise_for_status()
164
+ for post in r.json().get('data',{}).get('children',[]):
165
+ d = post.get('data',{})
166
+ title = d.get('title','')
167
+ selftext = d.get('selftext','')
168
+ # Reddit selftext often has real content
169
+ text = f"{title}. {selftext}" if len(selftext) > 80 else title
170
+ item = make_item(text, category, 'reddit',
171
+ {'subreddit': subreddit, 'score': d.get('score',0)})
172
+ if item: items.append(item)
173
+ except Exception: pass
174
+ return items
175
+
176
+ def fetch_wikipedia(topic, category):
177
+ """Fetch real Wikipedia article content β€” actual knowledge, not taglines."""
178
+ try:
179
+ url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{quote_plus(topic)}"
180
+ r = requests.get(url, headers=HEADERS, timeout=10)
181
+ r.raise_for_status()
182
+ data = r.json()
183
+ title = data.get('title','')
184
+ extract = data.get('extract','') # Wikipedia gives full intro paragraph
185
+ if len(extract) < 80: return None
186
+ text = f"{title}: {extract}"
187
+ return make_item(text, category, 'wikipedia', {'title': title})
188
+ except Exception: return None
189
+
190
+ def fetch_hackernews(n=6):
191
+ items = []
192
+ try:
193
+ ids = requests.get(HN_TOP, headers=HEADERS, timeout=8).json()[:40]
194
+ chosen = random.sample(ids, min(n, len(ids)))
195
+ for sid in chosen:
196
+ try:
197
+ story = requests.get(HN_ITEM.format(sid), headers=HEADERS, timeout=5).json()
198
+ title = story.get('title','')
199
+ text_body = story.get('text','')
200
+ text = f"{title}. {text_body}" if len(text_body)>60 else title
201
+ item = make_item(text, 'technology', 'hackernews',
202
+ {'score': story.get('score',0)})
203
+ if item: items.append(item)
204
+ time.sleep(0.05)
205
+ except Exception: continue
206
+ except Exception: pass
207
+ return items
208
+
209
+ # ── MAIN FETCHER ─────────────────────────────────────────────────────────────
210
+ class DataFetcher:
211
+ def __init__(self):
212
+ self.total_fetched = 0
213
+ self.source_counts = {'rss':0,'reddit':0,'wikipedia':0,'hackernews':0}
214
+ self.recent_items = deque(maxlen=100)
215
+ self.log = deque(maxlen=300)
216
+ self._wiki_idx = 0
217
+
218
+ def _log(self, msg):
219
+ ts = datetime.utcnow().strftime('%H:%M:%S')
220
+ self.log.appendleft(f"[{ts}] {msg}")
221
+
222
+ def fetch_round(self):
223
+ all_items = []
224
+
225
+ # 1. RSS β€” 2 random feeds
226
+ for _ in range(2):
227
+ cat = random.choice(list(RSS_FEEDS.keys()))
228
+ url = random.choice(RSS_FEEDS[cat])
229
+ items = fetch_rss(cat, url)
230
+ all_items.extend(items)
231
+ self.source_counts['rss'] += len(items)
232
+ self._log(f"πŸ“° RSS [{cat.upper()}] +{len(items)} ← {url.split('/')[2]}")
233
+
234
+ # 2. Reddit
235
+ cat, sub = random.choice(REDDIT_SUBS)
236
+ items = fetch_reddit(cat, sub)
237
+ all_items.extend(items)
238
+ self.source_counts['reddit'] += len(items)
239
+ self._log(f"🟠 Reddit [r/{sub}] +{len(items)}")
240
+
241
+ # 3. Wikipedia β€” rotate through topics (REAL knowledge content)
242
+ flat = [(cat, t) for cat, topics in WIKI_TOPICS for t in topics]
243
+ cat, topic = flat[self._wiki_idx % len(flat)]
244
+ self._wiki_idx += 1
245
+ item = fetch_wikipedia(topic, cat)
246
+ if item:
247
+ all_items.append(item)
248
+ self.source_counts['wikipedia'] += 1
249
+ self._log(f"πŸ“– Wikipedia: {topic}")
250
+
251
+ # 4. HackerNews (every other round)
252
+ if random.random() < 0.5:
253
+ items = fetch_hackernews(5)
254
+ all_items.extend(items)
255
+ self.source_counts['hackernews'] += len(items)
256
+ self._log(f"πŸ’» HackerNews +{len(items)}")
257
+
258
+ for item in all_items:
259
+ self.recent_items.appendleft(item)
260
+ self.total_fetched += len(all_items)
261
+ self._log(f"βœ… Round done β€” +{len(all_items)} | total {self.total_fetched}")
262
+ return all_items
263
+
264
+ def get_stats(self):
265
+ return {'total_fetched': self.total_fetched,
266
+ 'sources': dict(self.source_counts),
267
+ 'recent_log': list(self.log)[:30]}
268
+
269
+ def get_recent_items(self, n=20):
270
+ return list(self.recent_items)[:n]