Skip to content

Commit 720761f

Browse files
committed
Working new main input to query app descriptions and extract only the base keywords
1 parent 9d3839a commit 720761f

5 files changed

Lines changed: 194 additions & 111 deletions

File tree

adscrawler/app_stores/process_keywords.py

Lines changed: 75 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# noqa: PLC0415
2+
import datetime
23
import os
34
import re
45
from collections import Counter
@@ -12,14 +13,17 @@
1213

1314
from adscrawler.dbcon.connection import PostgresCon
1415
from adscrawler.dbcon.queries import (
16+
delete_and_insert,
1517
query_all_store_app_descriptions,
18+
query_apps_to_process_keywords,
1619
query_keywords_base,
1720
upsert_df,
18-
query_keywords_base,
19-
delete_and_insert,
20-
query_apps_to_process_keywords,
2121
)
2222

23+
from adscrawler.config import get_logger
24+
25+
logger = get_logger(__name__)
26+
2327
# Custom stopwords to remove personal pronouns & other irrelevant words
2428
CUSTOM_STOPWORDS = {
2529
"your",
@@ -75,6 +79,26 @@ def clean_text(text: str) -> str:
7579
return re.sub(r"[^a-zA-Z\s]", ". ", text.lower())
7680

7781

82+
def clean_df_text(df: pd.DataFrame, column: str) -> pd.DataFrame:
83+
# Note these are same as clean_text function
84+
df[column] = (
85+
df[column]
86+
.str.replace("\r", ". ")
87+
.replace("\n", ". ")
88+
.replace("\t", ". ")
89+
.replace("\xa0", ". ")
90+
.replace("•", ". ")
91+
.replace("'", "")
92+
.replace("’", "")
93+
.replace("-", " ")
94+
.replace(r"\bhttp\S*", "", regex=True)
95+
.replace(r"\bwww\S*", "", regex=True)
96+
.replace(r"[^a-zA-Z\s]", ". ", regex=True)
97+
.str.lower()
98+
)
99+
return df
100+
101+
78102
def count_tokens(phrase: str) -> int:
79103
"""Count the number of tokens in a phrase."""
80104
return len(word_tokenize(phrase))
@@ -177,8 +201,14 @@ def extract_unique_app_keywords_from_text(
177201
# Remove stopwords from filtered keywords
178202
filtered_keywords = [kw for kw in filtered_keywords if kw not in STOPWORDS]
179203

180-
matched_base_keywords = matched_base_keywords["keyword_text"].str.strip().tolist()
181-
combined_keywords = list(sorted(set(filtered_keywords + matched_base_keywords)))
204+
# keywords_base = query_keywords_base(database_connection)
205+
# matched_base_keywords = keywords_base[
206+
# keywords_base["keyword_text"].apply(lambda x: x in description_text)
207+
# ]
208+
209+
# matched_base_keywords = matched_base_keywords["keyword_text"].str.strip().tolist()
210+
# combined_keywords = list(sorted(set(filtered_keywords + matched_base_keywords)))
211+
combined_keywords = list(sorted(set(filtered_keywords)))
182212

183213
return combined_keywords
184214

@@ -191,22 +221,7 @@ def get_global_keywords(database_connection: PostgresCon) -> list[str]:
191221
language_slug="en", database_connection=database_connection
192222
)
193223

194-
# Note these are same as clean_text function
195-
df["description"] = (
196-
df["description"]
197-
.str.replace("\r", ". ")
198-
.replace("\n", ". ")
199-
.replace("\t", ". ")
200-
.replace("\xa0", ". ")
201-
.replace("•", ". ")
202-
.replace("'", "")
203-
.replace("’", "")
204-
.replace("-", " ")
205-
.replace(r"\bhttp\S*", "", regex=True)
206-
.replace(r"\bwww\S*", "", regex=True)
207-
.replace(r"[^a-zA-Z\s]", ". ", regex=True)
208-
.str.lower()
209-
)
224+
df = clean_df_text(df, "description")
210225

211226
from sklearn.feature_extraction.text import TfidfVectorizer # noqa: PLC0415
212227

@@ -261,107 +276,65 @@ def insert_global_keywords(database_connection: PostgresCon) -> None:
261276
)
262277

263278

264-
def process_app_keywords(database_connection: PostgresCon) -> None:
265-
"""Process app keywords."""
279+
def process_app_keywords(database_connection: PostgresCon, limit: int) -> None:
280+
"""Process app keywords.
281+
282+
While Python might be less efficient than SQL it's more flexible for
283+
the query input limiting which apps and when to run.
284+
This way apps can be processed in batches and only when really needed.
285+
"""
286+
logger.info(f"Extracting app keywords for {limit} apps")
287+
extract_app_keywords_from_descriptions(database_connection, limit)
288+
logger.info("Extracted app keywords finished")
266289

267290

268-
def process_keywords_for_app(
269-
description_df: pd.DataFrame, database_connection: PostgresCon
291+
def extract_app_keywords_from_descriptions(
292+
database_connection: PostgresCon, limit: int
270293
) -> None:
271-
"""Process keywords for an app."""
272-
description_df = query_apps_to_process_keywords(database_connection)
294+
"""Process keywords for app descriptions."""
295+
description_df = query_apps_to_process_keywords(database_connection, limit=limit)
273296
keywords_base = query_keywords_base(database_connection)
297+
keywords_base["keyword_text"] = (
298+
" " + keywords_base["keyword_text"].str.lower() + " "
299+
)
274300
description_df["description_text"] = (
275-
description_df["description_short"] + " " + description_df["description"]
301+
" "
302+
+ description_df["description_short"]
303+
+ " "
304+
+ description_df["description"]
305+
+ " "
276306
).str.lower()
307+
description_df = clean_df_text(description_df, "description_text")
277308
all_keywords_dfs = []
309+
logger.info(f"Processing {len(description_df)} app descriptions")
278310
for _i, row in description_df.iterrows():
279-
print(f"Processing description: {_i}/{len(description_df)}")
311+
logger.debug(f"Processing app description: {_i}/{len(description_df)}")
280312
description_id = row["description_id"]
313+
store_app = row["store_app"]
281314
description_text = row["description_text"]
282315
matched_base_keywords = keywords_base[
283-
keywords_base["keyword_text"].apply(lambda x: x in description_text)
316+
keywords_base["keyword_text"].apply(
317+
lambda x, text=description_text: x in text
318+
)
284319
]
285-
keywords_df = pd.DataFrame(matched_base_keywords, columns=["keyword_text"])
320+
keywords_df = pd.DataFrame(
321+
matched_base_keywords, columns=["keyword_text", "keyword_id"]
322+
)
286323
keywords_df["description_id"] = description_id
287-
keywords_df = keywords_df[["keyword_text", "description_id"]]
324+
keywords_df["store_app"] = store_app
288325
all_keywords_dfs.append(keywords_df)
289326
main_keywords_df = pd.concat(all_keywords_dfs)
290-
main_keywords_df = main_keywords_df.merge(
291-
keywords_base, on="keyword_text", how="left"
292-
)
293-
main_keywords_df = main_keywords_df[["description_id", "keyword_id"]]
294-
table_name = "description_keywords"
295-
insert_columns = ["description_id", "keyword_id"]
296-
key_columns = ["description_id", "keyword_id"]
327+
main_keywords_df = main_keywords_df[["store_app", "description_id", "keyword_id"]]
328+
main_keywords_df["extracted_at"] = datetime.datetime.now(tz=datetime.UTC)
329+
table_name = "app_keywords_extracted"
330+
insert_columns = ["store_app", "description_id", "keyword_id", "extracted_at"]
331+
logger.info(f"Delete and insert {len(main_keywords_df)} app keywords")
297332
delete_and_insert(
298333
df=main_keywords_df,
299334
table_name=table_name,
300335
schema="public",
301336
database_connection=database_connection,
302337
insert_columns=insert_columns,
303-
key_columns=key_columns,
304-
delete_by_keys=["description_id"],
338+
delete_by_keys=["store_app"],
305339
delete_keys_have_duplicates=True,
306340
)
307-
308-
all_matches = []
309-
for _i, keyword in enumerate(keywords_base["keyword_text"].unique()):
310-
print(f"Processing keyword: {_i}/{len(keywords_base['keyword_text'].unique())}")
311-
# Find all descriptions containing this keyword
312-
mask = description_df["description_text"].str.contains(
313-
re.escape(keyword), # Escape special regex characters
314-
case=False,
315-
regex=True,
316-
na=False,
317-
)
318-
matched_descriptions = description_df[mask]["description_id"]
319-
# Create rows for each match
320-
for desc_id in matched_descriptions:
321-
keyword_rows = keywords_base[keywords_base["keyword_text"] == keyword]
322-
for _, kw_row in keyword_rows.iterrows():
323-
all_matches.append({"description_id": desc_id, **kw_row.to_dict()})
324-
325-
all_keywords_df = pd.DataFrame(all_matches)
326-
327-
pattern = "|".join(re.escape(kw) for kw in keywords_base["keyword_text"].unique())
328-
329-
# Find matches
330-
all_matches = []
331-
for _i, desc_row in description_df.iterrows():
332-
print(f"Processing description: {_i}/{len(description_df)}")
333-
matches = keywords_base[
334-
keywords_base["keyword_text"].apply(
335-
lambda x: bool(
336-
re.search(
337-
r"\b" + re.escape(x) + r"\b", desc_row["description_text"]
338-
)
339-
)
340-
)
341-
]
342-
if len(matches) > 0:
343-
matches["description_id"] = desc_row["description_id"]
344-
all_matches.append(matches)
345-
346-
all_keywords_df = (
347-
pd.concat(all_matches, ignore_index=True) if all_matches else pd.DataFrame()
348-
)
349-
350-
# Method 2: Cross join + filter (best for large datasets)
351-
description_df["key"] = 1
352-
keywords_base["key"] = 1
353-
354-
# Create cartesian product
355-
cross_join = (
356-
description_df[["description_id", "description_text", "key"]]
357-
.merge(keywords_base, on="key")
358-
.drop("key", axis=1)
359-
)
360-
361-
# Filter matches
362-
cross_join["match"] = cross_join.apply(
363-
lambda row: row["keyword_text"] in row["description_text"], axis=1
364-
)
365-
all_keywords_df = cross_join[cross_join["match"]].drop(
366-
["match", "description_text"], axis=1
367-
)

adscrawler/app_stores/scrape_stores.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -827,7 +827,7 @@ def upsert_store_apps_descriptions(
827827
key_columns=key_columns,
828828
md5_key_columns=["description", "description_short"],
829829
database_connection=database_connection,
830-
# return_rows=True,
830+
on_conflict_update=False,
831831
)
832832

833833

adscrawler/dbcon/queries.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ def upsert_df(
265265
return_rows: bool = False,
266266
schema: str | None = None,
267267
md5_key_columns: list[str] | None = None,
268+
on_conflict_update: bool = True,
268269
log: bool = False,
269270
) -> pd.DataFrame | None:
270271
"""Perform an "upsert" on a PostgreSQL table from a DataFrame.
@@ -273,11 +274,11 @@ def upsert_df(
273274
274275
Parameters
275276
----------
276-
data_frame : pandas.DataFrame
277+
df : pandas.DataFrame
277278
The DataFrame to be upserted.
278279
table_name : str
279280
The name of the target table.
280-
engine : sqlalchemy.engine.Engine
281+
database_connection : Connection
281282
The SQLAlchemy Engine to use.
282283
schema : str, optional
283284
The name of the schema containing the target table.
@@ -290,10 +291,19 @@ def upsert_df(
290291
md5_key_columns: list of str columns (usually >1000 chars needs this)
291292
that have Postgresql md5() used in the UNIQUE INDEX
292293
allows upsert without hitting index size limits
294+
on_conflict_update: bool, optional
295+
Whether to update the existing rows on conflict, default True
293296
log : bool, optional
294297
Print generated SQL statement for debugging.
295298
"""
296299

300+
# Validate parameters
301+
if not on_conflict_update and return_rows:
302+
raise ValueError(
303+
"return_rows=True cannot be used with on_conflict_update=False "
304+
"because DO NOTHING doesn't guarantee the returned rows were actually inserted"
305+
)
306+
297307
raw_conn = database_connection.engine.raw_connection()
298308

299309
if "crawled_date" in df.columns and df["crawled_date"].isna().all():
@@ -317,24 +327,29 @@ def upsert_df(
317327
)
318328
else:
319329
conflict_columns = SQL(", ").join(map(Identifier, key_columns))
320-
update_set = SQL(", ").join(
321-
SQL("{0} = EXCLUDED.{0}").format(Identifier(col)) for col in all_columns
322-
)
330+
331+
if on_conflict_update:
332+
update_set = SQL(", ").join(
333+
SQL("{0} = EXCLUDED.{0}").format(Identifier(col)) for col in all_columns
334+
)
335+
action_clause = SQL("DO UPDATE SET {update_set}").format(update_set=update_set)
336+
else:
337+
action_clause = SQL("DO NOTHING")
323338

324339
# Upsert query without RETURNING clause
325340
upsert_query = SQL(
326341
"""
327342
INSERT INTO {table} ({columns})
328343
VALUES ({placeholders})
329344
ON CONFLICT ({conflict_columns})
330-
DO UPDATE SET {update_set}
345+
{action_clause}
331346
"""
332347
).format(
333348
table=table_identifier,
334349
columns=columns,
335350
placeholders=placeholders,
336351
conflict_columns=conflict_columns,
337-
update_set=update_set,
352+
action_clause=action_clause,
338353
)
339354

340355
sel_where_conditions = SQL(" AND ").join(
@@ -1131,11 +1146,14 @@ def query_apps_to_api_scan(
11311146
return df
11321147

11331148

1134-
def query_apps_to_process_keywords(database_connection: PostgresCon) -> pd.DataFrame:
1149+
def query_apps_to_process_keywords(
1150+
database_connection: PostgresCon, limit: int = 10000
1151+
) -> pd.DataFrame:
11351152
"""Query apps to process keywords."""
11361153
df = pd.read_sql(
11371154
QUERY_APPS_TO_PROCESS_KEYWORDS,
11381155
con=database_connection.engine,
1156+
params={"mylimit": limit},
11391157
)
11401158
return df
11411159

0 commit comments

Comments
 (0)