Spaces:
Sleeping
Sleeping
Upload 2 files
Browse files- chatbot.py +212 -0
- 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]
|