install.packages(c("tidyverse", "tidytext", "textdata", "jsonlite",
                   "wordcloud", "RColorBrewer", "lubridate",
                   "scales", "knitr", "kableExtra"))

library(textdata)
lexicon_afinn()
## # A tibble: 2,477 × 2
##    word       value
##    <chr>      <dbl>
##  1 abandon       -2
##  2 abandoned     -2
##  3 abandons      -2
##  4 abducted      -2
##  5 abduction     -2
##  6 abductions    -2
##  7 abhor         -3
##  8 abhorred      -3
##  9 abhorrent     -3
## 10 abhors        -3
## # ℹ 2,467 more rows
lexicon_bing()
## # A tibble: 6,789 × 2
##    word        sentiment
##    <chr>       <chr>    
##  1 2-faced     negative 
##  2 2-faces     negative 
##  3 abnormal    negative 
##  4 abolish     negative 
##  5 abominable  negative 
##  6 abominably  negative 
##  7 abominate   negative 
##  8 abomination negative 
##  9 abort       negative 
## 10 aborted     negative 
## # ℹ 6,779 more rows
lexicon_nrc()
## # A tibble: 13,872 × 2
##    word        sentiment
##    <chr>       <chr>    
##  1 abacus      trust    
##  2 abandon     fear     
##  3 abandon     negative 
##  4 abandon     sadness  
##  5 abandoned   anger    
##  6 abandoned   fear     
##  7 abandoned   negative 
##  8 abandoned   sadness  
##  9 abandonment anger    
## 10 abandonment fear     
## # ℹ 13,862 more rows
library(tidyverse)
library(tidytext)
library(textdata)
library(jsonlite)
library(wordcloud)
library(RColorBrewer)
library(lubridate)
library(scales)
library(knitr)
library(kableExtra)
## add your NewsAPI secret in the quotation mark below
api_key <- Sys.getenv("NEWS_API_KEY")
fetch_news <- function(query, api_key, page_size = 20) {
  url <- paste0(
    "https://newsapi.org/v2/everything?",
    "q=",        URLencode(query, reserved = TRUE),
    "&language=en",
    "&sortBy=publishedAt",
    "&pageSize=", page_size,
    "&apiKey=",  api_key
  )

  response <- fromJSON(url, flatten = TRUE)
  articles <- as_tibble(response$articles)

  articles %>%
    rename_with(~ str_replace_all(.x, "\\.", "_")) %>%
    mutate(query = query)
}

news_raw <- bind_rows(
  fetch_news("Honda", api_key),
  fetch_news("Toyota", api_key),
  fetch_news("Nissan", api_key),
)

glimpse(news_raw)
## Rows: 59
## Columns: 10
## $ author      <chr> "Jonathan James Tan", "Rachit Thukral", "Germán Garcia Cas…
## $ title       <chr> "GAC GS3 Emzoom taken out on Sepang – just how sporty is t…
## $ description <chr> "I know what you’re thinking, because it was on my mind th…
## $ url         <chr> "https://paultan.org/2026/06/22/gac-gs3-emzoom-sepang-defe…
## $ urlToImage  <chr> "https://paultan.org/image/2026/06/GAC-GS3-Emzoom-Malaysia…
## $ publishedAt <chr> "2026-06-22T07:47:56Z", "2026-06-22T07:44:10Z", "2026-06-2…
## $ content     <chr> "I know what you’re thinking, because it was on my mind th…
## $ source_id   <chr> NA, NA, NA, NA, NA, "fox-sports", NA, NA, NA, NA, NA, NA, …
## $ source_name <chr> "Paul Tan's Automotive News", "Motorsport.com", "Motorspor…
## $ query       <chr> "Honda", "Honda", "Honda", "Honda", "Honda", "Honda", "Hon…
news_clean <- news_raw %>%
  filter(!is.na(.data$title)) %>%
  mutate(
    pub_date    = ymd_hms(.data$publishedAt, quiet = TRUE),
    pub_day     = as.Date(pub_date),
    title_clean = str_remove(.data$title, "\\s*-\\s*[^-]+$"),
    title_clean = str_squish(str_replace_all(title_clean, "[^[:alnum:][:space:]]", " ")),
    title_clean = str_to_lower(title_clean)
  ) %>%
  distinct(title_clean, .keep_all = TRUE)

cat("Total unique headlines:", nrow(news_clean), "\n")
## Total unique headlines: 53
news_clean %>%
  select(query, title_clean, any_of(c("source_name", "source", "sourceName")), pub_day) %>%
  head(10) %>%
  kable(caption = "Sample Cleaned Headlines") %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
Sample Cleaned Headlines
query title_clean source_name pub_day
Honda gac gs3 emzoom taken out on sepang just how sporty is this eight second 177 ps 270 nm b Paul Tan’s Automotive News 2026-06-22
Honda motogp riders react to marco bezzecchi s marshal slap and race ban Motorsport.com 2026-06-22
Honda everything you need to know about the first official motogp test with pirelli tyres Motorsport.com 2026-06-22
Honda bayliss breakthrough adds aussie flavour to knockhill bsb weekend Mcnews.com.au 2026-06-22
Honda czech gp rider quotes marquez wins ogura shines bezzecchi apologises Mcnews.com.au 2026-06-22
Honda 4 takeaways from christian lundgaard s worst to Fox Sports 2026-06-22
Honda brno moto2 moto3 ortolá steals it late danish makes history Mcnews.com.au 2026-06-22
Honda marquez masters brno as bezzecchi absence reshapes title race Mcnews.com.au 2026-06-22
Honda herlings and sacha coenen master montevarchi at mxgp of italy Mcnews.com.au 2026-06-21
Honda u s open 2026 keith mitchell s wild week ends with an extraordinary tournament record Yahoo Entertainment 2026-06-21
news_tokens <- news_clean %>%
  select(query, title_clean) %>%
  unnest_tokens(word, title_clean) %>%
  anti_join(stop_words, by = "word") %>%
  filter(!str_detect(word, "^\\d+$"), nchar(word) > 2)

top_words <- news_tokens %>%
  count(word, sort = TRUE) %>%
  slice_head(n = 20)

top_words %>%
  kable(caption = "Top 20 Words Across All Headlines") %>%
  kable_styling(bootstrap_options = "striped", full_width = FALSE)
Top 20 Words Across All Headlines
word n
diego 8
san 8
cup 7
race 7
nascar 6
win 5
wins 5
christian 4
corey 4
heim 4
lundgaard 4
nissan 4
results 4
america 3
bezzecchi 3
car 3
history 3
indycar 3
road 3
toyota 3
top_words %>%
  mutate(word = fct_reorder(word, n)) %>%
  ggplot(aes(x = n, y = word, fill = n)) +
  geom_col(show.legend = FALSE) +
  scale_fill_gradient(low = "#a8d8ea", high = "#0077b6") +
  labs(
    title    = "Top 20 Words in News Headlines",
    subtitle = "Honda, Toyota, Nissan",
    x        = "Count",
    y        = NULL,
    caption  = "Source: NewsAPI"
  ) +
  theme_minimal(base_size = 13)

word_freq <- news_tokens %>%
  count(word, sort = TRUE) %>%
  filter(n >= 2)

set.seed(42)
wordcloud(
  words  = word_freq$word,
  freq   = word_freq$n,
  min.freq = 1,
  max.words = 80,
  random.order = FALSE,
  colors = brewer.pal(8, "Dark2"),
  scale  = c(3.5, 0.5)
)
title("News Headline Word Cloud — Trending Tickers")

afinn <- get_sentiments("afinn")

sentiment_afinn <- news_tokens %>%
  inner_join(afinn, by = "word") %>%
  group_by(query) %>%
  summarise(
    total_words    = n(),
    mean_sentiment = round(mean(value), 3),
    sum_sentiment  = sum(value),
    .groups = "drop"
  ) %>%
  arrange(desc(mean_sentiment))

sentiment_afinn %>%
  kable(caption = "AFINN Sentiment Score by Topic") %>%
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
AFINN Sentiment Score by Topic
query total_words mean_sentiment sum_sentiment
Toyota 15 1.533 23
Honda 17 0.706 12
Nissan 6 0.000 0
sentiment_afinn %>%
  mutate(query = fct_reorder(query, mean_sentiment),
         sentiment_dir = ifelse(mean_sentiment >= 0, "Positive", "Negative")) %>%
  ggplot(aes(x = mean_sentiment, y = query, fill = sentiment_dir)) +
  geom_col(width = 0.6) +
  scale_fill_manual(values = c("Positive" = "#2ecc71", "Negative" = "#e74c3c")) +
  geom_vline(xintercept = 0, linetype = "dashed", color = "gray40") +
  labs(
    title   = "Mean AFINN Sentiment Score by Topic",
    x       = "Mean Sentiment Score",
    y       = NULL,
    fill    = NULL,
    caption = "Source: NewsAPI"
  ) +
  theme_minimal(base_size = 13) +
  theme(legend.position = "top")

bing <- get_sentiments("bing")

sentiment_bing <- news_tokens %>%
  inner_join(bing, by = "word") %>%
  count(query, sentiment) %>%
  pivot_wider(
    names_from  = sentiment,
    values_from = n,
    values_fill = list(n = 0)
  ) %>%
  mutate(
    positive = coalesce(positive, 0L),
    negative = coalesce(negative, 0L),
    net_sentiment = positive - negative
  )

sentiment_bing %>%
  kable(caption = "Bing Sentiment Count by Topic") %>%
  kable_styling(bootstrap_options = "striped", full_width = FALSE)
Bing Sentiment Count by Topic
query negative positive net_sentiment
Honda 12 12 0
Nissan 3 2 -1
Toyota 5 10 5
news_tokens %>%
  inner_join(bing, by = "word") %>%
  count(word, sentiment, sort = TRUE) %>%
  group_by(sentiment) %>%
  slice_head(n = 10) %>%
  ungroup() %>%
  mutate(word = reorder_within(word, n, sentiment)) %>%
  ggplot(aes(x = n, y = word, fill = sentiment)) +
  geom_col(show.legend = FALSE) +
  facet_wrap(~ sentiment, scales = "free_y") +
  scale_y_reordered() +
  scale_fill_manual(values = c("positive" = "#2ecc71", "negative" = "#e74c3c")) +
  labs(
    title   = "Top Positive & Negative Words in Headlines",
    x       = "Count", y = NULL,
    caption = "Source: NewsAPI"
  ) +
  theme_minimal(base_size = 12)

nrc <- get_sentiments("nrc")

emotion_nrc <- news_tokens %>%
  inner_join(nrc, by = "word") %>%
  filter(!sentiment %in% c("positive", "negative")) %>%
  count(query, sentiment) %>%
  group_by(query) %>%
  mutate(prop = n / sum(n))

ggplot(emotion_nrc, aes(x = sentiment, y = prop, fill = query)) +
  geom_col(position = "dodge") +
  scale_y_continuous(labels = percent_format()) +
  scale_fill_brewer(palette = "Set2") +
  labs(
    title   = "NRC Emotion Proportions by Topic",
    x       = "Emotion",
    y       = "Proportion of Emotional Words",
    fill    = "Topic",
    caption = "Source: NewsAPI"
  ) +
  theme_minimal(base_size = 12) +
  theme(axis.text.x = element_text(angle = 30, hjust = 1),
        legend.position = "top")

tfidf_words <- news_tokens %>%
  count(query, word) %>%
  bind_tf_idf(word, query, n) %>%
  group_by(query) %>%
  slice_max(tf_idf, n = 6) %>%
  ungroup()

tfidf_words %>%
  mutate(word = reorder_within(word, tf_idf, query)) %>%
  ggplot(aes(x = tf_idf, y = word, fill = query)) +
  geom_col(show.legend = FALSE) +
  facet_wrap(~ query, scales = "free_y", ncol = 2) +
  scale_y_reordered() +
  scale_fill_brewer(palette = "Set1") +
  labs(
    title    = "Top TF-IDF Terms by Topic",
    subtitle = "Words most distinctive to each news topic",
    x        = "TF-IDF Score", y = NULL,
    caption  = "Source: NewsAPI"
  ) +
  theme_minimal(base_size = 12)

summary_tbl <- sentiment_afinn %>%
  left_join(sentiment_bing %>% select(query, positive, negative, net_sentiment),
            by = "query") %>%
  rename(
    Topic           = query,
    `Words Matched` = total_words,
    `Mean AFINN`    = mean_sentiment,
    `AFINN Sum`     = sum_sentiment,
    Positive        = positive,
    Negative        = negative,
    `Net (Bing)`    = net_sentiment
  )

summary_tbl %>%
  kable(caption = "Sentiment Summary: All Topics") %>%
  kable_styling(bootstrap_options = c("striped", "hover", "condensed"),
                full_width = FALSE) %>%
  column_spec(3, color = ifelse(summary_tbl$`Mean AFINN` >= 0, "green", "red"))
Sentiment Summary: All Topics
Topic Words Matched Mean AFINN AFINN Sum Positive Negative Net (Bing)
Toyota 15 1.533 23 10 5 5
Honda 17 0.706 12 12 12 0
Nissan 6 0.000 0 2 3 -1