Skip to content

Commit 7cd41bb

Browse files
authored
Scrape child courses before programs and fix missing child course sections (#3580)
1 parent af69194 commit 7cd41bb

4 files changed

Lines changed: 345 additions & 42 deletions

File tree

learning_resources/tasks.py

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
build_program_children_content_bulk,
4646
html_to_markdown,
4747
load_course_blocklist,
48+
programs_needing_children_heal,
4849
resource_unpublished_actions,
4950
resource_upserted_actions,
5051
strip_markdown_images,
@@ -670,31 +671,55 @@ def sync_canvas_courses(canvas_course_ids, overwrite):
670671
@app.task(bind=True)
671672
def scrape_marketing_pages(self):
672673
"""
673-
Scrape marketing pages (for programs and courses)
674-
and store them as content files if they dont exist
674+
Scrape marketing pages for programs and courses and store them as content
675+
files. Child courses are scraped before their parent programs so a program's
676+
children section is built from child marketing pages that already exist.
677+
Programs whose stored page is missing its children section (and whose
678+
children content is now available) are re-scraped to heal them.
675679
"""
676680
log.info("Running scrape_marketing_pages task")
677-
resource_ids = set(
681+
resource_types = dict(
678682
LearningResource.objects.filter(
679683
published=True, resource_type__in=["course", "program"]
680-
).values_list("id", flat=True)
684+
).values_list("id", "resource_type")
681685
)
682-
683686
existing_page_resource_ids = set(
684-
ContentFile.objects.filter(file_type="marketing_page").values_list(
687+
ContentFile.objects.filter(file_type=MARKETING_PAGE_FILE_TYPE).values_list(
685688
"learning_resource_id", flat=True
686689
)
687690
)
688-
missing_pages = list(resource_ids.difference(existing_page_resource_ids))
689691

690-
tasks = [
692+
missing_ids = set(resource_types) - existing_page_resource_ids
693+
missing_course_ids = sorted(
694+
rid for rid in missing_ids if resource_types[rid] == "course"
695+
)
696+
program_ids_with_pages = {
697+
rid
698+
for rid in existing_page_resource_ids
699+
if resource_types.get(rid) == "program"
700+
}
701+
program_ids = {rid for rid in missing_ids if resource_types[rid] == "program"}
702+
program_ids |= programs_needing_children_heal(program_ids_with_pages)
703+
704+
course_tasks = [
691705
marketing_page_for_resources.si(ids)
692-
for ids in chunks(
693-
missing_pages,
694-
chunk_size=settings.QDRANT_CHUNK_SIZE,
695-
)
706+
for ids in chunks(missing_course_ids, chunk_size=settings.QDRANT_CHUNK_SIZE)
696707
]
697-
scrape_tasks = celery.group(tasks)
708+
program_tasks = [
709+
marketing_page_for_resources.si(ids)
710+
for ids in chunks(sorted(program_ids), chunk_size=settings.QDRANT_CHUNK_SIZE)
711+
]
712+
713+
if course_tasks and program_tasks:
714+
scrape_tasks = celery.chain(
715+
celery.group(course_tasks), celery.group(program_tasks)
716+
)
717+
elif course_tasks:
718+
scrape_tasks = celery.group(course_tasks)
719+
elif program_tasks:
720+
scrape_tasks = celery.group(program_tasks)
721+
else:
722+
return None
698723
return self.replace(scrape_tasks)
699724

700725

@@ -723,8 +748,20 @@ def marketing_page_for_resources(resource_ids):
723748

724749
for learning_resource in resources:
725750
marketing_page_url = learning_resource.url
726-
scraper = scraper_for_site(marketing_page_url)
727-
page_content = scraper.scrape()
751+
try:
752+
scraper = scraper_for_site(marketing_page_url)
753+
page_content = scraper.scrape()
754+
except Exception:
755+
# Isolate per-resource failures so one bad page can't fail the whole
756+
# chunk. When these tasks are chained (course group -> program group),
757+
# a failed task poisons the chord header and the program group never
758+
# runs, so keep this batch succeeding for pages that do scrape.
759+
log.exception(
760+
"Failed to scrape marketing page for resource %s (%s)",
761+
learning_resource.id,
762+
marketing_page_url,
763+
)
764+
continue
728765
if page_content:
729766
content_file, _ = ContentFile.objects.update_or_create(
730767
learning_resource=learning_resource,

learning_resources/tasks_test.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,55 @@ def test_marketing_page_for_resources_with_webdriver(mocker, settings):
619619
)
620620

621621

622+
@pytest.mark.django_db
623+
def test_marketing_page_for_resources_isolates_scrape_failures(mocker):
624+
"""A single resource's scrape failure must not fail the whole chunk.
625+
626+
When course and program tasks are chained, a chunk that raises poisons the
627+
chord header and the program group never runs, so per-resource failures are
628+
logged and skipped rather than propagated.
629+
"""
630+
bad_course = models.LearningResource.objects.create(
631+
title="Bad Course",
632+
url="https://example.com/bad-course",
633+
resource_type="course",
634+
published=True,
635+
)
636+
good_course = models.LearningResource.objects.create(
637+
title="Good Course",
638+
url="https://example.com/good-course",
639+
resource_type="course",
640+
published=True,
641+
)
642+
643+
good_scraper = mocker.Mock()
644+
good_scraper.scrape.return_value = "<html><body><p>ok</p></body></html>"
645+
646+
def fake_scraper_for_site(url):
647+
if url == bad_course.url:
648+
msg = "scraper boom"
649+
raise RuntimeError(msg)
650+
return good_scraper
651+
652+
mocker.patch(
653+
"learning_resources.tasks.scraper_for_site",
654+
side_effect=fake_scraper_for_site,
655+
)
656+
mocker.patch("learning_resources.tasks.html_to_markdown", return_value="ok")
657+
mock_generate_embeddings = mocker.patch("vector_search.tasks.generate_embeddings")
658+
659+
# Must not raise despite the bad course failing
660+
marketing_page_for_resources([bad_course.id, good_course.id])
661+
662+
assert not models.ContentFile.objects.filter(learning_resource=bad_course).exists()
663+
good_cf = models.ContentFile.objects.get(
664+
learning_resource=good_course, file_type=MARKETING_PAGE_FILE_TYPE
665+
)
666+
mock_generate_embeddings.delay.assert_called_once_with(
667+
[good_cf.id], "content_file", overwrite=True
668+
)
669+
670+
622671
@pytest.mark.django_db
623672
def test_marketing_page_for_program_appends_children(mocker, settings):
624673
"""Test that marketing_page_for_resources appends program children content"""
@@ -780,6 +829,104 @@ def test_scrape_marketing_pages(mocker, settings, mocked_celery):
780829
mock_group.assert_called_once()
781830

782831

832+
@pytest.mark.django_db
833+
def test_scrape_marketing_pages_orders_courses_before_programs(
834+
mocker, settings, mocked_celery
835+
):
836+
"""Courses are scraped in a group that runs before the programs group."""
837+
settings.QDRANT_CHUNK_SIZE = 10
838+
course = models.LearningResource.objects.create(
839+
title="Course",
840+
url="https://example.com/course",
841+
resource_type="course",
842+
published=True,
843+
)
844+
program = models.LearningResource.objects.create(
845+
title="Program",
846+
url="https://example.com/program",
847+
resource_type="program",
848+
published=True,
849+
)
850+
si_mock = mocker.patch("learning_resources.tasks.marketing_page_for_resources.si")
851+
# Make each .si(...) call's return value identify which ids it was built
852+
# from, so the args passed into celery.group(...) can be told apart.
853+
si_mock.side_effect = lambda ids: ("si", tuple(ids))
854+
855+
with pytest.raises(mocked_celery.replace_exception_class):
856+
scrape_marketing_pages.delay()
857+
858+
# A chain enforces ordering (not a single flat group).
859+
assert mocked_celery.chain.called
860+
# Course task built before program task; each in its own chunk here.
861+
queued_ids = [call.args[0] for call in si_mock.call_args_list]
862+
assert queued_ids[0] == [course.id]
863+
assert [program.id] in queued_ids
864+
assert queued_ids.index([course.id]) < queued_ids.index([program.id])
865+
866+
# Pin the guarantee to what is actually fed into celery.chain via
867+
# celery.group: Python evaluates chain's positional args left-to-right,
868+
# so the first group(...) call must be the course tasks and the second
869+
# must be the program tasks. This fails if the two arguments to
870+
# celery.chain(celery.group(...), celery.group(...)) are ever swapped.
871+
assert mocked_celery.group.call_count == 2
872+
first_group_tasks, second_group_tasks = (
873+
call.args[0] for call in mocked_celery.group.call_args_list
874+
)
875+
assert first_group_tasks == [("si", (course.id,))]
876+
assert second_group_tasks == [("si", (program.id,))]
877+
878+
879+
@pytest.mark.django_db
880+
def test_scrape_marketing_pages_queues_healable_programs(
881+
mocker, settings, mocked_celery
882+
):
883+
"""A program that already has a page but is missing its children section
884+
(with a child course page available) is queued for re-scrape.
885+
"""
886+
settings.QDRANT_CHUNK_SIZE = 10
887+
course = models.LearningResource.objects.create(
888+
title="Course",
889+
url="https://example.com/course",
890+
resource_type="course",
891+
published=True,
892+
)
893+
ContentFile.objects.create(
894+
learning_resource=course,
895+
file_type=MARKETING_PAGE_FILE_TYPE,
896+
file_extension=".md",
897+
key=course.url,
898+
content="Child copy.",
899+
published=True,
900+
)
901+
program = models.LearningResource.objects.create(
902+
title="Program",
903+
url="https://example.com/program",
904+
resource_type="program",
905+
published=True,
906+
)
907+
models.LearningResourceRelationship.objects.create(
908+
parent=program, child=course, relation_type="PROGRAM_COURSES"
909+
)
910+
# Program already has a page, but WITHOUT the children marker.
911+
ContentFile.objects.create(
912+
learning_resource=program,
913+
file_type=MARKETING_PAGE_FILE_TYPE,
914+
file_extension=".md",
915+
key=program.url,
916+
content="Program page, no children yet.",
917+
published=True,
918+
)
919+
si_mock = mocker.patch("learning_resources.tasks.marketing_page_for_resources.si")
920+
921+
with pytest.raises(mocked_celery.replace_exception_class):
922+
scrape_marketing_pages.delay()
923+
924+
queued_ids = [i for call in si_mock.call_args_list for i in call.args[0]]
925+
# Program re-queued for healing; course already has a page so it is NOT queued.
926+
assert program.id in queued_ids
927+
assert course.id not in queued_ids
928+
929+
783930
@pytest.mark.parametrize("canvas_ids", [["1"], None])
784931
def test_sync_canvas_courses(settings, mocker, django_assert_num_queries, canvas_ids):
785932
"""

0 commit comments

Comments
 (0)