Skip to content

Commit b04c509

Browse files
committed
updated moderator tool
1 parent 05fd605 commit b04c509

1 file changed

Lines changed: 134 additions & 33 deletions

File tree

reddit_cache_v2.py

Lines changed: 134 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"""
33
reddit_cache_v2.py
44
5-
This script uses PRAW (Python Reddit API Wrapper) to fetch and cache the newest 100 posts
5+
This script uses PRAW (Python Reddit API Wrapper) to fetch and cache the newest 1000 posts
66
from one or more subreddits, and generates various reports based on the local cache.
77
It is a new version that replaces direct HTTP requests with PRAW for more robust,
88
authenticated interactions with Reddit's API.
@@ -13,7 +13,7 @@
1313
- Generates reports (flair, monthly digest, show posts) from the cached data.
1414
- Checks for unformatted code in post selftexts and prompts the moderator interactively.
1515
- Retrieves the number of posts waiting in the mod queue and the number of unread modmail conversations.
16-
- Supports multiple output formats: JSON, human-readable ANSI report, and Markdown report.
16+
- Supports multiple output formats: machine-readable JSON, human-readable ANSI-colored report, and Markdown-formatted report.
1717
1818
Configuration:
1919
- PRAW will load credentials from praw.ini or from environment variables:
@@ -62,12 +62,14 @@ def format_help(self) -> str:
6262
# -- Configuration and File I/O Helpers --
6363

6464
def get_cache_folder(subreddit: str) -> str:
65+
"""Return the cache folder path for a given subreddit under 'caches'."""
6566
safe_subreddit = re.sub(r'[^\w-]', '_', subreddit)
6667
folder = os.path.join("caches", safe_subreddit)
6768
os.makedirs(folder, exist_ok=True)
6869
return folder
6970

7071
def get_config() -> Tuple[configparser.ConfigParser, str]:
72+
"""Load the configuration file (caches/app.ini). Create it if it doesn't exist."""
7173
config = configparser.ConfigParser()
7274
config_path = os.path.join("caches", "app.ini")
7375
if os.path.exists(config_path):
@@ -77,10 +79,12 @@ def get_config() -> Tuple[configparser.ConfigParser, str]:
7779
return config, config_path
7880

7981
def save_config(config: configparser.ConfigParser, config_path: str) -> None:
82+
"""Save the configuration to the specified config_path."""
8083
with open(config_path, "w", encoding="utf-8") as configfile:
8184
config.write(configfile)
8285

8386
def load_cached_posts(subreddit: str) -> List[Dict[str, Any]]:
87+
"""Load cached posts from the given subreddit's cache folder."""
8488
folder = get_cache_folder(subreddit)
8589
posts = []
8690
for filename in os.listdir(folder):
@@ -98,6 +102,7 @@ def load_cached_posts(subreddit: str) -> List[Dict[str, Any]]:
98102
# -- PRAW API Wrappers --
99103

100104
def submission_to_dict(submission: praw.models.Submission) -> Dict[str, Any]:
105+
"""Convert a PRAW submission object into a dictionary with selected fields."""
101106
return {
102107
"id": submission.id,
103108
"title": submission.title,
@@ -108,8 +113,12 @@ def submission_to_dict(submission: praw.models.Submission) -> Dict[str, Any]:
108113
}
109114

110115
def fetch_posts(subreddit: str) -> Optional[List[Dict[str, Any]]]:
116+
"""
117+
Fetch the newest 1000 posts for the given subreddit using PRAW.
118+
Returns a list of post dictionaries or None if an error occurs.
119+
"""
111120
try:
112-
submissions = reddit.subreddit(subreddit).new(limit=100)
121+
submissions = reddit.subreddit(subreddit).new(limit=1000)
113122
posts = [submission_to_dict(sub) for sub in submissions]
114123
if not posts:
115124
logger.error(f"No posts found for r/{subreddit}.")
@@ -120,6 +129,10 @@ def fetch_posts(subreddit: str) -> Optional[List[Dict[str, Any]]]:
120129
return None
121130

122131
def cache_post(subreddit: str, post_data: Dict[str, Any]) -> Tuple[Dict[str, Any], bool]:
132+
"""
133+
Cache a post's data locally under caches/<subreddit>.
134+
Returns the post data and a flag indicating whether it was newly cached.
135+
"""
123136
folder = get_cache_folder(subreddit)
124137
post_id = post_data.get("id")
125138
if not post_id:
@@ -140,6 +153,7 @@ def cache_post(subreddit: str, post_data: Dict[str, Any]) -> Tuple[Dict[str, Any
140153
return post_data, True
141154

142155
def fetch_modqueue_count(subreddit: str) -> int:
156+
"""Return the number of posts currently waiting in the moderator queue for the given subreddit."""
143157
try:
144158
mod_items = list(reddit.subreddit(subreddit).mod.modqueue(limit=None))
145159
return len(mod_items)
@@ -148,6 +162,7 @@ def fetch_modqueue_count(subreddit: str) -> int:
148162
return 0
149163

150164
def fetch_modmail_count(subreddit: str) -> int:
165+
"""Return the number of unread modmail conversations for the given subreddit."""
151166
try:
152167
conversations = list(reddit.subreddit(subreddit).modmail.conversations(limit=None))
153168
unread_count = sum(1 for conv in conversations if conv.state == "new")
@@ -159,16 +174,18 @@ def fetch_modmail_count(subreddit: str) -> int:
159174
# -- Report Generation --
160175

161176
def generate_flair_report(subreddit: str, report_limit: Optional[int] = None) -> Dict[str, int]:
177+
"""Generate a summary report of unique flair texts from the cached posts."""
162178
posts = load_cached_posts(subreddit)
163179
if report_limit is not None:
164180
posts = posts[:report_limit]
165-
flair_counts = {}
181+
flair_counts: Dict[str, int] = {}
166182
for post in posts:
167183
flair = post.get("link_flair_text") or "None"
168184
flair_counts[flair] = flair_counts.get(flair, 0) + 1
169185
return flair_counts
170186

171187
def generate_show_report(subreddit: str, n: int) -> List[Dict[str, Any]]:
188+
"""Generate a report showing selected fields for the last n cached posts."""
172189
posts_list = load_cached_posts(subreddit)
173190
selected = posts_list[:n]
174191
return [
@@ -183,6 +200,10 @@ def generate_show_report(subreddit: str, n: int) -> List[Dict[str, Any]]:
183200

184201
def generate_monthly_digest_report(subreddit: str, digest_pattern: str = "Monthly Digest",
185202
limit: Optional[int] = None) -> Dict[str, Any]:
203+
"""
204+
Generate a Monthly Digest report section by scanning cached posts whose titles match a pattern.
205+
Returns a dictionary containing the header, narrative, and digest posts, or a message if none are found.
206+
"""
186207
posts_list = load_cached_posts(subreddit)
187208
pattern = re.compile(digest_pattern, re.IGNORECASE)
188209
posts_list = [post for post in posts_list if pattern.search(post.get("title", ""))]
@@ -212,6 +233,9 @@ def generate_monthly_digest_report(subreddit: str, digest_pattern: str = "Monthl
212233
# -- Code Formatting Helpers --
213234

214235
def remove_fenced_code(text: str) -> str:
236+
"""
237+
Remove fenced code blocks (delimited by lines starting with ```) from text.
238+
"""
215239
lines = text.splitlines()
216240
result_lines = []
217241
inside = False
@@ -224,6 +248,9 @@ def remove_fenced_code(text: str) -> str:
224248
return "\n".join(result_lines)
225249

226250
def remove_indented_code(text: str) -> str:
251+
"""
252+
Remove indented code blocks (lines indented by 4+ spaces or a tab) from text.
253+
"""
227254
lines = text.splitlines()
228255
result_lines = []
229256
for line in lines:
@@ -233,52 +260,103 @@ def remove_indented_code(text: str) -> str:
233260
return "\n".join(result_lines)
234261

235262
def remove_inline_code(text: str) -> str:
263+
"""
264+
Remove inline code spans (enclosed in single backticks) from text.
265+
"""
236266
return re.sub(r"`[^`]+`", "", text)
237267

238268
def clean_text(text: str) -> str:
269+
"""
270+
Unescape HTML entities and remove fenced, indented, and inline code blocks from text.
271+
"""
239272
unescaped = html.unescape(text)
240273
cleaned = remove_fenced_code(unescaped)
241274
cleaned = remove_indented_code(cleaned)
242275
cleaned = remove_inline_code(cleaned)
243276
return cleaned
244277

278+
# List of inline code patterns (not anchored to the start) for detecting code anywhere in a line.
279+
inline_code_patterns = [
280+
re.compile(r'(?:#\s*)?include\s*<[^>]+>', re.IGNORECASE),
281+
re.compile(r'\bvoid\s+\w+\s*\([^)]*\)\s*{', re.IGNORECASE),
282+
re.compile(r'\bfor\s*\([^)]*\)', re.IGNORECASE),
283+
re.compile(r'\bwhile\s*\([^)]*\)', re.IGNORECASE),
284+
re.compile(r'\bif\s*\([^)]*\)', re.IGNORECASE),
285+
re.compile(r'\bSerial\.println\s*\(', re.IGNORECASE),
286+
re.compile(r'\bpinMode\s*\(', re.IGNORECASE),
287+
re.compile(r'\bdigitalWrite\s*\(', re.IGNORECASE),
288+
re.compile(r'\banalogRead\s*\(', re.IGNORECASE),
289+
re.compile(r'\banalogWrite\s*\(', re.IGNORECASE),
290+
re.compile(r'printf\s*\(', re.IGNORECASE)
291+
]
292+
293+
def count_inline_code_patterns(line: str) -> int:
294+
"""
295+
Count the number of occurrences of code-like patterns in a line.
296+
"""
297+
count = 0
298+
for pattern in inline_code_patterns:
299+
count += len(pattern.findall(line))
300+
return count
301+
245302
def is_code_line(line: str) -> bool:
246-
code_patterns = [
247-
re.compile(r'^\s*(?:#\s*)?include\s*<[^>]+>', re.IGNORECASE),
248-
re.compile(r'^\s*\bvoid\s+\w+\s*\([^)]*\)\s*{', re.IGNORECASE),
249-
re.compile(r'^\s*\bfor\s*\([^)]*\)', re.IGNORECASE),
250-
re.compile(r'^\s*\bwhile\s*\([^)]*\)', re.IGNORECASE),
251-
re.compile(r'^\s*\bif\s*\([^)]*\)', re.IGNORECASE),
252-
re.compile(r'^\s*\bSerial\.println\s*\(', re.IGNORECASE),
253-
re.compile(r'^\s*\bpinMode\s*\(', re.IGNORECASE),
254-
re.compile(r'^\s*\bdigitalWrite\s*\(', re.IGNORECASE),
255-
re.compile(r'^\s*\banalogRead\s*\(', re.IGNORECASE),
256-
re.compile(r'^\s*\banalogWrite\s*\(', re.IGNORECASE),
257-
re.compile(r'^\s*printf\s*\(', re.IGNORECASE)
258-
]
259-
for pattern in code_patterns:
303+
"""
304+
Check if a line likely contains Arduino/C/C++ code using common patterns.
305+
"""
306+
for pattern in inline_code_patterns:
260307
if pattern.search(line):
261308
return True
262309
return False
263310

264-
def has_unformatted_code(text: str) -> bool:
311+
def has_unformatted_code(text: str, inline_threshold: int = 3, multiline_threshold: int = 3) -> bool:
312+
"""
313+
Determine if text contains unformatted code.
314+
315+
This function now checks in two ways:
316+
1. **Inline Check:**
317+
If any single non-empty line contains at least `inline_threshold` matches of code-like patterns,
318+
it is flagged as unformatted.
319+
2. **Multiline Check:**
320+
If there are at least `multiline_threshold` consecutive lines that look like code, it is flagged.
321+
322+
**Additional Heuristic:**
323+
If the total number of non-empty lines exceeds 50 and less than 30% of them look like code, then
324+
the text is presumed to be well-formatted (even if some lines match) and will not be flagged.
325+
326+
The thresholds can be adjusted via parameters.
327+
"""
265328
cleaned = clean_text(text)
266-
lines = cleaned.splitlines()
267-
code_run = 0
329+
lines = [line for line in cleaned.splitlines() if line.strip() != ""]
330+
331+
# Heuristic: For long posts, if only a small fraction of lines appear as code, assume it is well formatted.
332+
total_lines = len(lines)
333+
if total_lines > 50:
334+
code_lines = sum(1 for line in lines if is_code_line(line))
335+
if (code_lines / total_lines) < 0.3:
336+
return False
337+
338+
# Inline check: Each non-empty line is checked for inline code pattern occurrences.
339+
for line in lines:
340+
if count_inline_code_patterns(line) >= inline_threshold:
341+
return True
342+
343+
# Multiline check: Check for consecutive lines that look like code.
344+
consecutive = 0
268345
for line in lines:
269-
if line.strip() == "":
270-
continue
271346
if is_code_line(line):
272-
code_run += 1
273-
if code_run >= 3:
347+
consecutive += 1
348+
if consecutive >= multiline_threshold:
274349
return True
275350
else:
276-
code_run = 0
351+
consecutive = 0
277352
return False
278353

279354
# -- Output Functions --
280355

281356
def print_markdown(final_output: Dict[str, Any], filters_applied: Dict[str, Any]) -> None:
357+
"""
358+
Print a Markdown-formatted report of the final output.
359+
"""
282360
md_lines = []
283361
md_lines.append("# Monthly Digest Report\n")
284362
for subreddit, result in final_output["results"].items():
@@ -344,11 +422,14 @@ def print_markdown(final_output: Dict[str, Any], filters_applied: Dict[str, Any]
344422
md_lines.append(f"- **Total network retrievals (over time):** {gs.get('global_network_retrievals', 0)}")
345423
md_lines.append(f"- **Total cached posts (global):** {gs.get('global_cached_posts', 0)}\n")
346424
md_lines.append("## Filters and options applied")
347-
for key, value in filters_applied.items():
425+
for key, value in final_output.get("filters_applied", {}).items():
348426
md_lines.append(f"- **{key}:** {value}")
349427
print("\n".join(md_lines))
350428

351429
def print_human_readable(final_output: Dict[str, Any], filters_applied: Dict[str, Any]) -> None:
430+
"""
431+
Print a human-readable, colorful, ANSI report of the final output.
432+
"""
352433
print(f"{Fore.GREEN}=== Human Readable Report ==={Style.RESET_ALL}")
353434
for subreddit, result in final_output["results"].items():
354435
print(f"{Fore.BLUE}Subreddit: {subreddit}{Style.RESET_ALL}")
@@ -413,10 +494,29 @@ def print_human_readable(final_output: Dict[str, Any], filters_applied: Dict[str
413494
print(f" {Fore.LIGHTGREEN_EX}Total network retrievals (over time): {gs.get('global_network_retrievals', 0)}{Style.RESET_ALL}")
414495
print(f" {Fore.LIGHTGREEN_EX}Total cached posts (global): {gs.get('global_cached_posts', 0)}{Style.RESET_ALL}")
415496
print(f"\n{Fore.YELLOW}Filters applied:{Style.RESET_ALL}")
416-
for key, value in filters_applied.items():
497+
for key, value in final_output.get("filters_applied", {}).items():
417498
print(f" {key}: {value}")
418499

500+
# -- Code Formatting Check --
501+
419502
def check_code_format_violations(subreddit: str, limit: Optional[int] = None) -> List[Dict[str, Any]]:
503+
"""
504+
Interactively scan cached posts for code formatting violations.
505+
506+
For each post, any code outside properly formatted blocks is inspected.
507+
A violation is flagged if either:
508+
- A single non-empty line contains at least `inline_threshold` occurrences of code-like patterns, or
509+
- There are at least `multiline_threshold` consecutive lines that look like code.
510+
511+
An additional heuristic prevents flagging if the post is very long (more than 50 non-empty lines)
512+
and less than 30% of lines appear as code, assuming the code is well formatted.
513+
514+
In non-interactive mode (if the environment variable TEST_NONINTERACTIVE is set),
515+
the response is automatically "y" (flagging the post).
516+
"""
517+
inline_threshold = 3
518+
multiline_threshold = 3
519+
420520
config, config_path = get_config()
421521
no_violation_ids = config["CodeFormat"] if "CodeFormat" in config else {}
422522
violations: List[Dict[str, Any]] = []
@@ -428,7 +528,7 @@ def check_code_format_violations(subreddit: str, limit: Optional[int] = None) ->
428528
if post_id in no_violation_ids:
429529
continue
430530
selftext = post.get("selftext", "")
431-
if has_unformatted_code(selftext):
531+
if has_unformatted_code(selftext, inline_threshold, multiline_threshold):
432532
print(f"\n{Fore.CYAN}Potential Code Format Violation Detected:{Style.RESET_ALL}")
433533
print(f"Post ID: {post_id}")
434534
print(f"Title: {post.get('title', '')}")
@@ -440,14 +540,13 @@ def check_code_format_violations(subreddit: str, limit: Optional[int] = None) ->
440540
print("[DEBUG] TEST_NONINTERACTIVE is set; automatically flagging this post.")
441541
else:
442542
response = input("Does this post contain unformatted code? (y/n/s/c): ").strip().lower()
443-
444543
if response == "y":
445544
violations.append({
446545
"id": post_id,
447546
"title": post.get("title", ""),
448547
"violation": "Post contains unformatted source code. Please format your code in proper code blocks."
449-
})
450-
no_violation_ids[post_id] = "flagged" # Record that this post was flagged.
548+
})
549+
no_violation_ids[post_id] = "flagged"
451550
elif response == "n":
452551
no_violation_ids[post_id] = "n"
453552
elif response == "s":
@@ -459,9 +558,11 @@ def check_code_format_violations(subreddit: str, limit: Optional[int] = None) ->
459558
save_config(config, config_path)
460559
return violations
461560

561+
# -- Main Program --
562+
462563
def main() -> None:
463564
help_description = (
464-
f"{Fore.CYAN}Fetch and cache the newest 100 posts from one or more subreddits using PRAW, displaying only new posts and summary stats.\n"
565+
f"{Fore.CYAN}Fetch and cache the newest 1000 posts from one or more subreddits using PRAW, displaying only new posts and summary stats.\n"
465566
"If multiple subreddits are specified, a global summary is also provided.\n\n"
466567
"Positional arguments:\n"
467568
f" {Fore.MAGENTA}subreddits{Style.RESET_ALL} : One or more subreddit names to fetch posts from (default: arduino).\n\n"

0 commit comments

Comments
 (0)